diff --git a/Content.Client/Fluids/PuddleDebugOverlaySystem.cs b/Content.Client/Fluids/PuddleDebugOverlaySystem.cs new file mode 100644 index 0000000000..345e4b7fa6 --- /dev/null +++ b/Content.Client/Fluids/PuddleDebugOverlaySystem.cs @@ -0,0 +1,45 @@ +using System.Collections; +using Content.Shared.Fluids; +using Robust.Client.Graphics; + +namespace Content.Client.Fluids; + +public sealed class PuddleDebugOverlaySystem : SharedPuddleDebugOverlaySystem +{ + [Dependency] private readonly IOverlayManager _overlayManager = default!; + + public readonly Dictionary TileData = new(); + private PuddleOverlay? _overlay; + public override void Initialize() + { + base.Initialize(); + + SubscribeNetworkEvent(DisableOverlay); + SubscribeNetworkEvent(RenderDebugData); + } + + private void RenderDebugData(PuddleOverlayDebugMessage message) + { + TileData[message.GridUid] = message; + if (_overlay != null) + return; + + _overlay = new PuddleOverlay(); + _overlayManager.AddOverlay(_overlay); + } + + private void DisableOverlay(PuddleOverlayDisableMessage message) + { + TileData.Clear(); + if (_overlay == null) + return; + + _overlayManager.RemoveOverlay(_overlay); + _overlay = null; + } + + public PuddleDebugOverlayData[] GetData(EntityUid mapGridGridEntityId) + { + return TileData[mapGridGridEntityId].OverlayData; + } +} diff --git a/Content.Client/Fluids/PuddleOverlay.cs b/Content.Client/Fluids/PuddleOverlay.cs new file mode 100644 index 0000000000..ed5a6be63b --- /dev/null +++ b/Content.Client/Fluids/PuddleOverlay.cs @@ -0,0 +1,117 @@ +using Content.Shared.FixedPoint; +using Robust.Client.Graphics; +using Robust.Client.ResourceManagement; +using Robust.Shared.Enums; +using Robust.Shared.Map; + +namespace Content.Client.Fluids; + +public sealed class PuddleOverlay : Overlay +{ + [Dependency] private readonly IMapManager _mapManager = default!; + [Dependency] private readonly IEyeManager _eyeManager = default!; + [Dependency] private readonly IEntityManager _entityManager = default!; + [Dependency] private readonly IEntitySystemManager _entitySystemManager = default!; + private readonly PuddleDebugOverlaySystem _debugOverlaySystem; + + private readonly Color _heavyPuddle = new(0, 255, 255, 50); + private readonly Color _mediumPuddle = new(0, 150, 255, 50); + private readonly Color _lightPuddle = new(0, 50, 255, 50); + + private readonly Font _font; + + public override OverlaySpace Space => OverlaySpace.ScreenSpace | OverlaySpace.WorldSpace; + + public PuddleOverlay() + { + IoCManager.InjectDependencies(this); + _debugOverlaySystem = _entitySystemManager.GetEntitySystem(); + var cache = IoCManager.Resolve(); + _font = new VectorFont(cache.GetResource("/Fonts/NotoSans/NotoSans-Regular.ttf"), 8); + } + + protected override void Draw(in OverlayDrawArgs args) + { + switch (args.Space) + { + case OverlaySpace.ScreenSpace: + DrawScreen(args); + break; + case OverlaySpace.WorldSpace: + DrawWorld(args); + break; + } + } + + private void DrawWorld(in OverlayDrawArgs args) + { + var drawHandle = args.WorldHandle; + Box2 gridBounds; + var xformQuery = _entityManager.GetEntityQuery(); + + foreach (var gridId in _debugOverlaySystem.TileData.Keys) + { + if (!_mapManager.TryGetGrid(gridId, out var mapGrid)) + continue; + + var gridXform = xformQuery.GetComponent(gridId); + var (_, _, worldMatrix, invWorldMatrix) = gridXform.GetWorldPositionRotationMatrixWithInv(xformQuery); + gridBounds = invWorldMatrix.TransformBox(args.WorldBounds).Enlarged(mapGrid.TileSize * 2); + drawHandle.SetTransform(worldMatrix); + + foreach (var debugOverlayData in _debugOverlaySystem.GetData(mapGrid.GridEntityId)) + { + var centre = ((Vector2) debugOverlayData.Pos + 0.5f) * mapGrid.TileSize; + + // is the center of this tile visible + if (!gridBounds.Contains(centre)) + continue; + + var box = Box2.UnitCentered.Translated(centre); + drawHandle.DrawRect(box, Color.Blue, false); + drawHandle.DrawRect(box, ColorMap(debugOverlayData.CurrentVolume)); + } + } + + drawHandle.SetTransform(Matrix3.Identity); + } + + private void DrawScreen(in OverlayDrawArgs args) + { + var drawHandle = args.ScreenHandle; + var xformQuery = _entityManager.GetEntityQuery(); + + + foreach (var gridId in _debugOverlaySystem.TileData.Keys) + { + if (!_mapManager.TryGetGrid(gridId, out var mapGrid)) + continue; + + var gridXform = xformQuery.GetComponent(gridId); + var (_, _, matrix, invMatrix) = gridXform.GetWorldPositionRotationMatrixWithInv(xformQuery); + var gridBounds = invMatrix.TransformBox(args.WorldBounds).Enlarged(mapGrid.TileSize * 2); + + foreach (var debugOverlayData in _debugOverlaySystem.GetData(mapGrid.GridEntityId)) + { + var centre = ((Vector2) debugOverlayData.Pos + 0.5f) * mapGrid.TileSize; + + // // is the center of this tile visible + if (!gridBounds.Contains(centre)) + continue; + + var screenCenter = _eyeManager.WorldToScreen(matrix.Transform(centre)); + + drawHandle.DrawString(_font, screenCenter, debugOverlayData.CurrentVolume.ToString(), Color.White); + } + } + } + + private Color ColorMap(FixedPoint2 intensity) + { + var fraction = 1 - intensity / FixedPoint2.New(20f); + var result = fraction < 0.5f + ? Color.InterpolateBetween(_mediumPuddle, _heavyPuddle, fraction.Float() * 2) + : Color.InterpolateBetween(_lightPuddle, _mediumPuddle, (fraction.Float() - 0.5f) * 2); + return result; + } +} diff --git a/Content.IntegrationTests/Tests/Fluids/FluidSpillTest.cs b/Content.IntegrationTests/Tests/Fluids/FluidSpillTest.cs index 149d2856de..a99924562d 100644 --- a/Content.IntegrationTests/Tests/Fluids/FluidSpillTest.cs +++ b/Content.IntegrationTests/Tests/Fluids/FluidSpillTest.cs @@ -1,5 +1,6 @@ #nullable enable using System; +using System.Collections.Generic; using System.Threading.Tasks; using Content.Server.Fluids.Components; using Content.Server.Fluids.EntitySystems; @@ -31,13 +32,9 @@ public sealed class FluidSpill private readonly Direction[] _dirs = { Direction.East, - Direction.SouthEast, Direction.South, - Direction.SouthWest, Direction.West, - Direction.NorthWest, Direction.North, - Direction.NorthEast, }; @@ -46,12 +43,13 @@ public sealed class FluidSpill [Test] public async Task SpillEvenlyTest() { - await using var pairTracker = await PoolManager.GetServerClient(new PoolSettings{NoClient = true}); + await using var pairTracker = await PoolManager.GetServerClient(new PoolSettings { NoClient = true }); var server = pairTracker.Pair.Server; var mapManager = server.ResolveDependency(); var entityManager = server.ResolveDependency(); var spillSystem = server.ResolveDependency().GetEntitySystem(); var gameTiming = server.ResolveDependency(); + var puddleSystem = server.ResolveDependency().GetEntitySystem(); MapId mapId; EntityUid gridId = default; @@ -89,38 +87,44 @@ public sealed class FluidSpill var puddle = GetPuddle(entityManager, grid, _origin); Assert.That(puddle, Is.Not.Null); - Assert.That(puddle!.CurrentVolume, Is.EqualTo(FixedPoint2.New(20))); + 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(sidePuddle!.CurrentVolume, Is.EqualTo(FixedPoint2.New(10))); + Assert.That(puddleSystem.CurrentVolume(sidePuddle!.Owner, sidePuddle), Is.EqualTo(FixedPoint2.New(20))); } }); await pairTracker.CleanReturnAsync(); } - [Test] - public async Task SpillSmallOverflowTest() + public async Task SpillCorner() { - await using var pairTracker = await PoolManager.GetServerClient(); + await using var pairTracker = await PoolManager.GetServerClient(new PoolSettings { NoClient = true }); var server = pairTracker.Pair.Server; - var mapManager = server.ResolveDependency(); var entityManager = server.ResolveDependency(); var spillSystem = server.ResolveDependency().GetEntitySystem(); + var puddleSystem = server.ResolveDependency().GetEntitySystem(); var gameTiming = server.ResolveDependency(); MapId mapId; EntityUid gridId = default; + /* + In this test, if o is spillage puddle and # are walls, we want to ensure all tiles are empty (`.`) + o # . + # . . + . . . + */ await server.WaitPost(() => { mapId = mapManager.CreateMap(); var grid = mapManager.CreateGrid(mapId); + gridId = grid.GridEntityId; for (var x = 0; x < 3; x++) { @@ -130,49 +134,47 @@ public sealed class FluidSpill } } - gridId = grid.GridEntityId; + entityManager.SpawnEntity("WallReinforced", grid.GridTileToLocal(new Vector2i(0, 1))); + entityManager.SpawnEntity("WallReinforced", grid.GridTileToLocal(new Vector2i(1, 0))); }); + + var puddleOrigin = new Vector2i(0, 0); await server.WaitAssertion(() => { - var solution = new Solution("Water", FixedPoint2.New(20.01)); var grid = mapManager.GetGrid(gridId); - var tileRef = grid.GetTileRef(_origin); + var solution = new Solution("Water", FixedPoint2.New(100)); + var tileRef = grid.GetTileRef(puddleOrigin); var puddle = spillSystem.SpillAt(tileRef, solution, "PuddleSmear"); - Assert.That(puddle, Is.Not.Null); + Assert.That(GetPuddle(entityManager, grid, puddleOrigin), Is.Not.Null); }); var sTimeToWait = (int) Math.Ceiling(2f * gameTiming.TickRate); - await PoolManager.RunTicksSync(pairTracker.Pair, sTimeToWait); + 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(puddle!.CurrentVolume, Is.EqualTo(FixedPoint2.New(20))); + var puddle = GetPuddle(entityManager, grid, puddleOrigin); - // we don't know where a spill would happen - // but there should be only one - var emptyField = 0; - var fullField = 0; - foreach (var direction in _dirs) + Assert.That(puddle, Is.Not.Null); + Assert.That(puddleSystem.CurrentVolume(puddle!.Owner, puddle), Is.EqualTo(FixedPoint2.New(100))); + + for (var x = 0; x < 3; x++) { - var newPos = _origin.Offset(direction); - var sidePuddle = GetPuddle(entityManager, grid, newPos); - if (sidePuddle == null) + for (var y = 0; y < 3; y++) { - emptyField++; - } - else if (sidePuddle.CurrentVolume == FixedPoint2.Epsilon) - { - fullField++; + if (x == 0 && y == 0 || x == 0 && y == 1 || x == 1 && y == 0) + { + continue; + } + + var newPos = new Vector2i(x, y); + var sidePuddle = GetPuddle(entityManager, grid, newPos); + Assert.That(sidePuddle, Is.Null); } } - - Assert.That(emptyField, Is.EqualTo(7)); - Assert.That(fullField, Is.EqualTo(1)); }); await pairTracker.CleanReturnAsync(); diff --git a/Content.Server/Fluids/Components/FluidMapDataComponent.cs b/Content.Server/Fluids/Components/FluidMapDataComponent.cs new file mode 100644 index 0000000000..30ab0a32de --- /dev/null +++ b/Content.Server/Fluids/Components/FluidMapDataComponent.cs @@ -0,0 +1,35 @@ +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 +{ + /// + /// At what time will be checked next + /// + [DataField("goalTime", customTypeSerializer:typeof(TimeOffsetSerializer))] + public TimeSpan GoalTime; + + /// + /// Delay between two runs of + /// + [DataField("delay")] + public TimeSpan Delay = TimeSpan.FromSeconds(2); + + /// + /// Puddles to be expanded. + /// + [DataField("puddles")] public HashSet Puddles = new(); + + /// + /// Convenience method for setting GoalTime to + + /// + /// Time to which to add , defaults to current + public void UpdateGoal(TimeSpan? start = null) + { + GoalTime = (start ?? GoalTime) + Delay; + } +} diff --git a/Content.Server/Fluids/Components/FluidSpreaderComponent.cs b/Content.Server/Fluids/Components/FluidSpreaderComponent.cs deleted file mode 100644 index 1f7eff84d1..0000000000 --- a/Content.Server/Fluids/Components/FluidSpreaderComponent.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Content.Server.Fluids.EntitySystems; -using Content.Shared.Chemistry.Components; - -namespace Content.Server.Fluids.Components; - -[RegisterComponent] -[Access(typeof(FluidSpreaderSystem))] -public sealed class FluidSpreaderComponent : Component -{ - [ViewVariables] - public Solution OverflownSolution = default!; - - public bool Enabled { get; set; } -} diff --git a/Content.Server/Fluids/Components/PuddleComponent.cs b/Content.Server/Fluids/Components/PuddleComponent.cs index bb0c8536f8..2e2ab2483a 100644 --- a/Content.Server/Fluids/Components/PuddleComponent.cs +++ b/Content.Server/Fluids/Components/PuddleComponent.cs @@ -53,10 +53,7 @@ namespace Content.Server.Fluids.Components /// 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. /// - [DataField("opacityModifier")] - public float OpacityModifier = 1.0f; - - public FixedPoint2 OverflowLeft => CurrentVolume - OverflowVolume; + [DataField("opacityModifier")] public float OpacityModifier = 1.0f; [DataField("solution")] public string SolutionName { get; set; } = DefaultSolutionName; } diff --git a/Content.Server/Fluids/EntitySystems/EvaporationSystem.cs b/Content.Server/Fluids/EntitySystems/EvaporationSystem.cs index 42232ffab7..94ddaa2400 100644 --- a/Content.Server/Fluids/EntitySystems/EvaporationSystem.cs +++ b/Content.Server/Fluids/EntitySystems/EvaporationSystem.cs @@ -55,5 +55,20 @@ namespace Content.Server.Fluids.EntitySystems EntityManager.RemoveComponent(evaporationComponent.Owner, evaporationComponent); } } + + /// + /// Copy constructor to copy initial fields from source to destination. + /// + /// Entity to which we copy properties + /// Component that contains relevant properties + public void CopyConstruct(EntityUid destUid, EvaporationComponent srcEvaporation) + { + var destEvaporation = EntityManager.EnsureComponent(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 index 681cb78de6..db0cf6eaa5 100644 --- a/Content.Server/Fluids/EntitySystems/FluidSpreaderSystem.cs +++ b/Content.Server/Fluids/EntitySystems/FluidSpreaderSystem.cs @@ -1,237 +1,145 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; -using Content.Server.Chemistry.EntitySystems; using Content.Server.Fluids.Components; -using Content.Shared.Chemistry.Components; +using Content.Shared; using Content.Shared.Directions; -using Content.Shared.FixedPoint; using Content.Shared.Physics; using JetBrains.Annotations; using Robust.Shared.Map; -using Robust.Shared.Physics; -using Robust.Shared.Utility; +using Robust.Shared.Physics.Components; +using Robust.Shared.Timing; namespace Content.Server.Fluids.EntitySystems; +/// +/// Component that governs overflowing puddles. Controls how Puddles spread and updat +/// [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 SolutionContainerSystem _solutionContainerSystem = default!; - - private float _accumulatedTimeFrame; - private HashSet _fluidSpread = new(); - - public override void Initialize() + /// + /// Adds an overflow component to the map data component tracking overflowing puddles + /// + /// EntityUid of overflowing puddle + /// Optional PuddleComponent + /// Optional TransformComponent + public void AddOverflowingPuddle(EntityUid puddleUid, PuddleComponent? puddle = null, + TransformComponent? xform = null) { - SubscribeLocalEvent((uid, component, _) => - FluidSpreaderAdd(uid, component)); - } + if (!Resolve(puddleUid, ref puddle, ref xform, false) || xform.MapUid == null) + return; - public void AddOverflowingPuddle(PuddleComponent puddleComponent, Solution? solution = null) - { - var puddleSolution = solution; - if (puddleSolution == null && !_solutionContainerSystem.TryGetSolution(puddleComponent.Owner, - puddleComponent.SolutionName, - out puddleSolution)) return; + var mapId = xform.MapUid.Value; - var spreaderComponent = EntityManager.EnsureComponent(puddleComponent.Owner); - spreaderComponent.OverflownSolution = puddleSolution; - spreaderComponent.Enabled = true; - FluidSpreaderAdd(spreaderComponent.Owner, spreaderComponent); - } - - private void FluidSpreaderAdd(EntityUid uid, FluidSpreaderComponent component) - { - if (component.Enabled) - _fluidSpread.Add(uid); + EntityManager.EnsureComponent(mapId, out var component); + component.Puddles.Add(puddleUid); } public override void Update(float frameTime) { - _accumulatedTimeFrame += frameTime; - - if (!(_accumulatedTimeFrame >= 1.0f)) - return; - - _accumulatedTimeFrame -= 1.0f; - base.Update(frameTime); - - var remQueue = new RemQueue(); - foreach (var uid in _fluidSpread) + Span exploreDirections = stackalloc Direction[] { - if (!TryComp(uid, out MetaDataComponent? meta) || meta.Deleted) - { - remQueue.Add(uid); - continue; - } + Direction.North, + Direction.East, + Direction.South, + Direction.West, + }; + var puddles = new List(4); + var puddleQuery = GetEntityQuery(); + var xFormQuery = GetEntityQuery(); - if (meta.EntityPaused) + foreach (var fluidMapData in EntityQuery()) + { + if (fluidMapData.Puddles.Count == 0 || _gameTiming.CurTime <= fluidMapData.GoalTime) continue; - remQueue.Add(uid); - - SpreadFluid(uid); - } - - foreach (var removeUid in remQueue) - { - _fluidSpread.Remove(removeUid); - } - } - - private void SpreadFluid(EntityUid suid) - { - EntityUid GetOrCreate(EntityUid uid, string prototype, IMapGrid grid, Vector2i pos) - { - return uid == EntityUid.Invalid - ? EntityManager.SpawnEntity(prototype, grid.GridTileToWorld(pos)) - : uid; - } - - PuddleComponent? puddleComponent = null; - MetaDataComponent? metadataOriginal = null; - TransformComponent? transformOrig = null; - FluidSpreaderComponent? spreader = null; - - if (!Resolve(suid, ref puddleComponent, ref metadataOriginal, ref transformOrig, ref spreader, false)) - return; - - var prototypeName = metadataOriginal.EntityPrototype!.ID; - var visitedTiles = new HashSet(); - - if (!_mapManager.TryGetGrid(transformOrig.GridUid, out var mapGrid)) - return; - - // skip origin puddle - var nextToExpand = new List(9); - ExpandPuddle(suid, visitedTiles, mapGrid, nextToExpand); - - while (nextToExpand.Count > 0 - && spreader.OverflownSolution.CurrentVolume > FixedPoint2.Zero) - { - // we need to clamp to prevent spreading 0u fluids, while never going over spill limit - var divided = FixedPoint2.Clamp(spreader.OverflownSolution.CurrentVolume / nextToExpand.Count, - FixedPoint2.Epsilon, puddleComponent.OverflowVolume); - - foreach (var posAndUid in nextToExpand) + var newIteration = new HashSet(); + foreach (var puddleUid in fluidMapData.Puddles) { - var puddleUid = GetOrCreate(posAndUid.Uid, prototypeName, mapGrid, posAndUid.Pos); - - if (!TryComp(puddleUid, out PuddleComponent? puddle)) + if (!puddleQuery.TryGetComponent(puddleUid, out var puddle) + || !xFormQuery.TryGetComponent(puddleUid, out var transform) + || !_mapManager.TryGetGrid(transform.GridUid, out var mapGrid)) continue; - posAndUid.Uid = puddleUid; + puddles.Clear(); + var pos = transform.Coordinates; - if (puddle.CurrentVolume >= puddle.OverflowVolume) continue; + var totalVolume = _puddleSystem.CurrentVolume(puddle.Owner, puddle); + exploreDirections.Shuffle(); + foreach (var direction in exploreDirections) + { + var newPos = pos.Offset(direction); + if (CheckTile(puddle.Owner, puddle, newPos, mapGrid, + out var puddleComponent)) + { + puddles.Add(puddleComponent); + totalVolume += _puddleSystem.CurrentVolume(puddleComponent.Owner, puddleComponent); + } + } - // -puddle.OverflowLeft is guaranteed to be >= 0 - // iff puddle.CurrentVolume >= puddle.OverflowVolume - var split = FixedPoint2.Min(divided, -puddle.OverflowLeft); - _puddleSystem.TryAddSolution( - puddle.Owner, - spreader.OverflownSolution.SplitSolution(split), - false, false, puddle); - - // if solution is spent do not explore - if (spreader.OverflownSolution.CurrentVolume <= FixedPoint2.Zero) - return; + _puddleSystem.EqualizePuddles(puddle.Owner, puddles, totalVolume, newIteration, puddle); } - // find edges - nextToExpand = ExpandPuddles(nextToExpand, visitedTiles, mapGrid); + fluidMapData.Puddles.Clear(); + fluidMapData.Puddles.UnionWith(newIteration); + fluidMapData.UpdateGoal(_gameTiming.CurTime); } } - private List ExpandPuddles(List toExpand, - HashSet visitedTiles, - IMapGrid mapGrid) + + /// + /// Check a tile is valid for solution allocation. + /// + /// Entity Uid of original puddle + /// PuddleComponent attached to srcUid + /// at which to check tile + /// helper param needed to extract entities + /// either found or newly created PuddleComponent. + /// true if tile is empty or occupied by a non-overflowing puddle (or a puddle close to being overflowing) + private bool CheckTile(EntityUid srcUid, PuddleComponent srcPuddle, EntityCoordinates pos, IMapGrid mapGrid, + [NotNullWhen(true)] out PuddleComponent? puddle) { - var nextToExpand = new List(9); - foreach (var puddlePlacer in toExpand) - { - ExpandPuddle(puddlePlacer.Uid, visitedTiles, mapGrid, nextToExpand, puddlePlacer.Pos); - } - - return nextToExpand; - } - - private void ExpandPuddle(EntityUid puddle, - HashSet visitedTiles, - IMapGrid mapGrid, - List nextToExpand, - Vector2i? pos = null) - { - TransformComponent? transform = null; - - if (pos == null && !Resolve(puddle, ref transform, false)) - { - return; - } - - var puddlePos = pos ?? transform!.Coordinates.ToVector2i(EntityManager, _mapManager); - - // prepare next set of puddles to be expanded - foreach (var direction in SharedDirectionExtensions.RandomDirections().ToArray()) - { - var newPos = puddlePos.Offset(direction); - if (visitedTiles.Contains(newPos)) - continue; - - visitedTiles.Add(newPos); - - if (CanExpand(newPos, mapGrid, out var uid)) - nextToExpand.Add(new PuddlePlacer(newPos, (EntityUid) uid)); - } - } - - private bool CanExpand(Vector2i newPos, IMapGrid mapGrid, - [NotNullWhen(true)] out EntityUid? uid) - { - if (!mapGrid.TryGetTileRef(newPos, out var tileRef) + if (!mapGrid.TryGetTileRef(pos, out var tileRef) || tileRef.Tile.IsEmpty) { - uid = null; + puddle = null; return false; } - foreach (var entity in mapGrid.GetAnchoredEntities(newPos)) - { - IPhysBody? physics = null; - PuddleComponent? existingPuddle = null; + var puddleCurrentVolume = _puddleSystem.CurrentVolume(srcUid, srcPuddle); - // This is an invalid location - if (Resolve(entity, ref physics, false) - && (physics.CollisionLayer & (int) CollisionGroup.Impassable) != 0) + foreach (var entity in mapGrid.GetAnchoredEntities(pos)) + { + // If this is valid puddle check if we spread to it. + if (TryComp(entity, out PuddleComponent? existingPuddle)) { - uid = null; - return false; + // If current puddle has more volume than current we skip that field + if (_puddleSystem.CurrentVolume(existingPuddle.Owner, existingPuddle) >= puddleCurrentVolume) + { + puddle = null; + return false; + } + + puddle = existingPuddle; + return true; } - if (!Resolve(entity, ref existingPuddle, false)) - continue; - - uid = entity; - return true; + // if not puddle is this tile blocked by an object like wall or door + if (TryComp(entity, out PhysicsComponent? physComponent) + && physComponent.CanCollide + && (physComponent.CollisionLayer & (int) CollisionGroup.MobMask) != 0) + { + puddle = null; + return false; + } } - uid = EntityUid.Invalid; + puddle = _puddleSystem.SpawnPuddle(srcUid, pos, srcPuddle); return true; } } - -// Helper to allow mutable pair of (Pos, Uid) -internal sealed class PuddlePlacer -{ - internal Vector2i Pos; - internal EntityUid Uid; - - public PuddlePlacer(Vector2i pos, EntityUid uid) - { - Pos = pos; - Uid = uid; - } -} diff --git a/Content.Server/Fluids/EntitySystems/PuddleDebugDebugOverlaySystem.cs b/Content.Server/Fluids/EntitySystems/PuddleDebugDebugOverlaySystem.cs new file mode 100644 index 0000000000..606f4356fe --- /dev/null +++ b/Content.Server/Fluids/EntitySystems/PuddleDebugDebugOverlaySystem.cs @@ -0,0 +1,84 @@ +using Content.Server.Fluids.Components; +using Content.Shared.Fluids; +using Robust.Server.Player; +using Robust.Shared.Map; +using Robust.Shared.Timing; + +namespace Content.Server.Fluids.EntitySystems; + +public sealed class PuddleDebugDebugOverlaySystem : SharedPuddleDebugOverlaySystem +{ + [Dependency] private readonly IGameTiming _timing = default!; + [Dependency] private readonly IMapManager _mapManager = default!; + + private readonly HashSet _playerObservers = new(); + + + public bool ToggleObserver(IPlayerSession observer) + { + NextTick ??= _timing.CurTime + Cooldown; + + if (_playerObservers.Contains(observer)) + { + RemoveObserver(observer); + return false; + } + + _playerObservers.Add(observer); + return true; + } + + private void RemoveObserver(IPlayerSession observer) + { + if (!_playerObservers.Remove(observer)) + { + return; + } + + var message = new PuddleOverlayDisableMessage(); + RaiseNetworkEvent(message, observer.ConnectedClient); + } + + public override void Update(float frameTime) + { + base.Update(frameTime); + if (NextTick == null || _timing.CurTime < NextTick) + return; + + foreach (var session in _playerObservers) + { + if (session.AttachedEntity is not { Valid: true } entity) + continue; + + var transform = EntityManager.GetComponent(entity); + + var worldBounds = Box2.CenteredAround(transform.WorldPosition, + new Vector2(LocalViewRange, LocalViewRange)); + + + foreach (var grid in _mapManager.FindGridsIntersecting(transform.MapID, worldBounds)) + { + var data = new List(); + var gridUid = grid.GridEntityId; + + if (!Exists(gridUid)) + continue; + + foreach (var uid in grid.GetAnchoredEntities(worldBounds)) + { + PuddleComponent? puddle = null; + TransformComponent? xform = null; + if (!Resolve(uid, ref puddle, ref xform, false)) + continue; + + var pos = xform.Coordinates.ToVector2i(EntityManager, _mapManager); + data.Add(new PuddleDebugOverlayData(pos, puddle.CurrentVolume)); + } + + RaiseNetworkEvent(new PuddleOverlayDebugMessage(gridUid, data.ToArray())); + } + } + + NextTick = _timing.CurTime + Cooldown; + } +} diff --git a/Content.Server/Fluids/EntitySystems/PuddleSystem.cs b/Content.Server/Fluids/EntitySystems/PuddleSystem.cs index ce71d87474..ae877c0166 100644 --- a/Content.Server/Fluids/EntitySystems/PuddleSystem.cs +++ b/Content.Server/Fluids/EntitySystems/PuddleSystem.cs @@ -1,15 +1,18 @@ +using Content.Server.Chemistry.Components.SolutionManager; using Content.Server.Chemistry.EntitySystems; using Content.Server.Fluids.Components; -using Content.Shared.Chemistry.Components; using Content.Shared.Examine; using Content.Shared.FixedPoint; using Content.Shared.Fluids; -using Content.Shared.StepTrigger; +using Content.Shared.Slippery; 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.Player; +using Solution = Content.Shared.Chemistry.Components.Solution; namespace Content.Server.Fluids.EntitySystems { @@ -19,6 +22,9 @@ namespace Content.Server.Fluids.EntitySystems [Dependency] private readonly SolutionContainerSystem _solutionContainerSystem = default!; [Dependency] private readonly FluidSpreaderSystem _fluidSpreaderSystem = default!; [Dependency] private readonly StepTriggerSystem _stepTrigger = default!; + [Dependency] private readonly SlipperySystem _slipSystem = default!; + [Dependency] private readonly EvaporationSystem _evaporationSystem = default!; + public override void Initialize() { @@ -53,17 +59,23 @@ namespace Content.Server.Fluids.EntitySystems // Opacity based on level of fullness to overflow // Hard-cap lower bound for visibility reasons - var volumeScale = puddleComponent.CurrentVolume.Float() / puddleComponent.OverflowVolume.Float() * puddleComponent.OpacityModifier; + var volumeScale = CurrentVolume(puddleComponent.Owner, puddleComponent).Float() / + puddleComponent.OverflowVolume.Float() * + puddleComponent.OpacityModifier; var puddleSolution = _solutionContainerSystem.EnsureSolution(uid, puddleComponent.SolutionName); - bool hasEvaporationComponent = EntityManager.TryGetComponent(uid, out var evaporationComponent); + bool hasEvaporationComponent = + EntityManager.TryGetComponent(uid, out var evaporationComponent); bool canEvaporate = (hasEvaporationComponent && - (evaporationComponent!.LowerLimit == 0 || puddleComponent.CurrentVolume > evaporationComponent.LowerLimit)); + (evaporationComponent!.LowerLimit == 0 || + CurrentVolume(puddleComponent.Owner, puddleComponent) > + evaporationComponent.LowerLimit)); // "Does this puddle's sprite need changing to the wet floor effect sprite?" - bool changeToWetFloor = (puddleComponent.CurrentVolume <= puddleComponent.WetFloorEffectThreshold - && canEvaporate); + bool changeToWetFloor = (CurrentVolume(puddleComponent.Owner, puddleComponent) <= + puddleComponent.WetFloorEffectThreshold + && canEvaporate); appearanceComponent.SetData(PuddleVisuals.VolumeScale, volumeScale); appearanceComponent.SetData(PuddleVisuals.SolutionColor, puddleSolution.Color); @@ -73,12 +85,12 @@ namespace Content.Server.Fluids.EntitySystems private void UpdateSlip(EntityUid entityUid, PuddleComponent puddleComponent) { if ((puddleComponent.SlipThreshold == FixedPoint2.New(-1) || - puddleComponent.CurrentVolume < puddleComponent.SlipThreshold) && + CurrentVolume(puddleComponent.Owner, puddleComponent) < puddleComponent.SlipThreshold) && TryComp(entityUid, out StepTriggerComponent? stepTrigger)) { _stepTrigger.SetActive(entityUid, false, stepTrigger); } - else if (puddleComponent.CurrentVolume >= puddleComponent.SlipThreshold) + else if (CurrentVolume(puddleComponent.Owner, puddleComponent) >= puddleComponent.SlipThreshold) { var comp = EnsureComp(entityUid); _stepTrigger.SetActive(entityUid, true, comp); @@ -121,7 +133,7 @@ namespace Content.Server.Fluids.EntitySystems } /// - /// + /// Try to add solution to . /// /// Puddle to which we add /// Solution that is added to puddleComponent @@ -140,23 +152,15 @@ namespace Content.Server.Fluids.EntitySystems if (addedSolution.TotalVolume == 0 || !_solutionContainerSystem.TryGetSolution(puddleComponent.Owner, puddleComponent.SolutionName, - out var puddleSolution)) + out var solution)) { return false; } - var result = _solutionContainerSystem - .TryMixAndOverflow(puddleComponent.Owner, puddleSolution, addedSolution, puddleComponent.OverflowVolume, - out var overflowSolution); - - if (checkForOverflow && overflowSolution != null) + solution.AddSolution(addedSolution); + if (checkForOverflow && IsOverflowing(puddleUid, puddleComponent)) { - _fluidSpreaderSystem.AddOverflowingPuddle(puddleComponent, overflowSolution); - } - - if (!result) - { - return false; + _fluidSpreaderSystem.AddOverflowingPuddle(puddleComponent.Owner, puddleComponent); } RaiseLocalEvent(puddleComponent.Owner, new SolutionChangedEvent(), true); @@ -171,6 +175,46 @@ namespace Content.Server.Fluids.EntitySystems return true; } + /// + /// Given a large srcPuddle and smaller destination puddles, this method will equalize their + /// + /// puddle that donates liquids to other puddles + /// List of puddles that we want to equalize, their puddle should be less than sourcePuddleComponent + /// Total volume of src and destination puddle + /// optional parameter, that after equalization adds all still overflowing puddles. + /// puddleComponent for + public void EqualizePuddles(EntityUid srcPuddle, List destinationPuddles, + FixedPoint2 totalVolume, + HashSet? stillOverflowing = null, + PuddleComponent? sourcePuddleComponent = null) + { + if (!Resolve(srcPuddle, ref sourcePuddleComponent) + || !_solutionContainerSystem.TryGetSolution(srcPuddle, sourcePuddleComponent.SolutionName, + out var srcSolution)) + return; + + var dividedVolume = totalVolume / (destinationPuddles.Count + 1); + + foreach (var destPuddle in destinationPuddles) + { + if (!_solutionContainerSystem.TryGetSolution(destPuddle.Owner, destPuddle.SolutionName, + out var destSolution)) + continue; + + var takeAmount = FixedPoint2.Max(0, dividedVolume - destSolution.CurrentVolume); + TryAddSolution(destPuddle.Owner, srcSolution.SplitSolution(takeAmount), false, false, destPuddle); + if (stillOverflowing != null && IsOverflowing(destPuddle.Owner, destPuddle)) + { + stillOverflowing.Add(destPuddle.Owner); + } + } + + if (stillOverflowing != null && srcSolution.CurrentVolume > sourcePuddleComponent.OverflowVolume) + { + stillOverflowing.Add(srcPuddle); + } + } + /// /// Whether adding this solution to this puddle would overflow. /// @@ -183,7 +227,34 @@ namespace Content.Server.Fluids.EntitySystems if (!Resolve(uid, ref puddle)) return false; - return puddle.CurrentVolume + solution.TotalVolume > puddle.OverflowVolume; + return CurrentVolume(uid, puddle) + solution.TotalVolume > puddle.OverflowVolume; + } + + /// + /// Whether adding this solution to this puddle would overflow. + /// + /// Uid of owning entity + /// Puddle ref param + /// + private bool IsOverflowing(EntityUid uid, PuddleComponent? puddle = null) + { + if (!Resolve(uid, ref puddle)) + return false; + + return CurrentVolume(uid, puddle) > puddle.OverflowVolume; + } + + public PuddleComponent SpawnPuddle(EntityUid srcUid, EntityCoordinates pos, PuddleComponent? srcPuddleComponent = null) + { + MetaDataComponent? metadata = null; + Resolve(srcUid, ref srcPuddleComponent, ref metadata); + + var prototype = metadata?.EntityPrototype?.ID ?? "PuddleSmear"; // TODO Spawn a entity based on another entity + + var destUid = EntityManager.SpawnEntity(prototype, pos); + var destPuddle = EntityManager.EnsureComponent(destUid); + + return destPuddle; } } } diff --git a/Content.Server/Fluids/ShowFluidsCommand.cs b/Content.Server/Fluids/ShowFluidsCommand.cs new file mode 100644 index 0000000000..f122eadea7 --- /dev/null +++ b/Content.Server/Fluids/ShowFluidsCommand.cs @@ -0,0 +1,32 @@ +using Content.Server.Administration; +using Content.Server.Fluids.EntitySystems; +using Content.Shared.Administration; +using Robust.Server.Player; +using Robust.Shared.Console; + +namespace Content.Server.Fluids; + +[AdminCommand(AdminFlags.Debug)] +public sealed class ShowFluidsCommand : IConsoleCommand +{ + [Dependency] private readonly IEntitySystemManager _entitySystem = default!; + public string Command => "showfluids"; + public string Description => "Toggles seeing puddle debug overlay."; + public string Help => $"Usage: {Command}"; + public void Execute(IConsoleShell shell, string argStr, string[] args) + { + var player = shell.Player as IPlayerSession; + if (player == null) + { + shell.WriteLine("You must be a player to use this command."); + return; + } + + var fluidDebug = _entitySystem.GetEntitySystem(); + var enabled = fluidDebug.ToggleObserver(player); + + shell.WriteLine(enabled + ? "Enabled the puddle debug overlay." + : "Disabled the puddle debug overlay."); + } +} diff --git a/Content.Shared/Directions/SharedDirectionExtensions.cs b/Content.Shared/Directions/SharedDirectionExtensions.cs index 7b4e278fde..65d4ed0ea9 100644 --- a/Content.Shared/Directions/SharedDirectionExtensions.cs +++ b/Content.Shared/Directions/SharedDirectionExtensions.cs @@ -1,83 +1,14 @@ -using Content.Shared.Maps; +using System.Collections; +using System.Linq; using Robust.Shared.Map; using Robust.Shared.Random; -namespace Content.Shared.Directions +namespace Content.Shared.Directions; + +public static class SharedDirectionExtensions { - public static class SharedDirectionExtensions + public static EntityCoordinates Offset(this EntityCoordinates coordinates, Direction direction) { - /// - /// Gets random directions until none are left - /// - /// An enumerable of the directions. - public static IEnumerable RandomDirections() - { - var directions = new[] - { - Direction.East, - Direction.SouthEast, - Direction.South, - Direction.SouthWest, - Direction.West, - Direction.NorthWest, - Direction.North, - Direction.NorthEast, - }; - - var robustRandom = IoCManager.Resolve(); - var n = directions.Length; - - while (n > 1) - { - n--; - var k = robustRandom.Next(n + 1); - var value = directions[k]; - directions[k] = directions[n]; - directions[n] = value; - } - - foreach (var direction in directions) - { - yield return direction; - } - } - - /// - /// Gets tiles in random directions from the given one. - /// - /// An enumerable of the adjacent tiles. - public static IEnumerable AdjacentTilesRandom(this TileRef tile, bool ignoreSpace = false) - { - return tile.GridPosition().AdjacentTilesRandom(ignoreSpace); - } - - /// - /// Gets tiles in random directions from the given one. - /// - /// An enumerable of the adjacent tiles. - public static IEnumerable AdjacentTilesRandom(this EntityCoordinates coordinates, bool ignoreSpace = false) - { - foreach (var direction in RandomDirections()) - { - var adjacent = coordinates.Offset(direction).GetTileRef(); - - if (adjacent == null) - { - continue; - } - - if (ignoreSpace && adjacent.Value.Tile.IsEmpty) - { - continue; - } - - yield return adjacent.Value; - } - } - - public static EntityCoordinates Offset(this EntityCoordinates coordinates, Direction direction) - { - return coordinates.Offset(direction.ToVec()); - } + return coordinates.Offset(direction.ToVec()); } } diff --git a/Content.Shared/Fluids/SharedPuddleDebugOverlaySystem.cs b/Content.Shared/Fluids/SharedPuddleDebugOverlaySystem.cs new file mode 100644 index 0000000000..df0a05fa64 --- /dev/null +++ b/Content.Shared/Fluids/SharedPuddleDebugOverlaySystem.cs @@ -0,0 +1,50 @@ +using Content.Shared.FixedPoint; +using Robust.Shared.Serialization; + +namespace Content.Shared.Fluids; + +public abstract class SharedPuddleDebugOverlaySystem : EntitySystem +{ + protected const float LocalViewRange = 16; + protected TimeSpan? NextTick = null; + protected TimeSpan Cooldown = TimeSpan.FromSeconds(0.5f); +} + +/// +/// Message for disable puddle overlay +/// +[Serializable, NetSerializable] +public sealed class PuddleOverlayDisableMessage : EntityEventArgs +{ +} + +/// +/// Message for puddle overlay display data +/// +[Serializable, NetSerializable] +public sealed class PuddleOverlayDebugMessage : EntityEventArgs +{ + public PuddleDebugOverlayData[] OverlayData { get; } + + public EntityUid GridUid { get; } + + + public PuddleOverlayDebugMessage(EntityUid gridUid, PuddleDebugOverlayData[] overlayData) + { + GridUid = gridUid; + OverlayData = overlayData; + } +} + +[Serializable, NetSerializable] +public readonly struct PuddleDebugOverlayData +{ + public readonly Vector2i Pos; + public readonly FixedPoint2 CurrentVolume; + + public PuddleDebugOverlayData(Vector2i pos, FixedPoint2 currentVolume) + { + CurrentVolume = currentVolume; + Pos = pos; + } +} diff --git a/Content.Shared/SharedArrayExtension.cs b/Content.Shared/SharedArrayExtension.cs new file mode 100644 index 0000000000..dd02e3784e --- /dev/null +++ b/Content.Shared/SharedArrayExtension.cs @@ -0,0 +1,28 @@ +using Robust.Shared.Random; + +namespace Content.Shared; + +public static class SharedArrayExtension +{ + /// + /// Randomizes the array mutating it in the process + /// + /// array being randomized + /// source of randomization + /// type of array element + public static void Shuffle(this Span array, IRobustRandom? random = null) + { + var n = array.Length; + if (n <= 1) + return; + IoCManager.Resolve(ref random); + + while (n > 1) + { + n--; + var k = random.Next(n + 1); + (array[k], array[n]) = + (array[n], array[k]); + } + } +} diff --git a/Content.Shared/Slippery/SlipperySystem.cs b/Content.Shared/Slippery/SlipperySystem.cs index 0bf3274125..a5ea35207b 100644 --- a/Content.Shared/Slippery/SlipperySystem.cs +++ b/Content.Shared/Slippery/SlipperySystem.cs @@ -100,6 +100,14 @@ namespace Content.Shared.Slippery _adminLogger.Add(LogType.Slip, LogImpact.Low, $"{ToPrettyString(other):mob} slipped on collision with {ToPrettyString(component.Owner):entity}"); } + + public void CopyConstruct(EntityUid destUid, SlipperyComponent srcSlip) + { + var destEvaporation = EntityManager.EnsureComponent(destUid); + destEvaporation.SlipSound = srcSlip.SlipSound; + destEvaporation.ParalyzeTime = srcSlip.ParalyzeTime; + destEvaporation.LaunchForwardsMultiplier = srcSlip.LaunchForwardsMultiplier; + } } /// diff --git a/Content.Shared/StepTrigger/Systems/StepTriggerSystem.cs b/Content.Shared/StepTrigger/Systems/StepTriggerSystem.cs index d4614fe565..ee8a895d3e 100644 --- a/Content.Shared/StepTrigger/Systems/StepTriggerSystem.cs +++ b/Content.Shared/StepTrigger/Systems/StepTriggerSystem.cs @@ -176,6 +176,20 @@ public sealed class StepTriggerSystem : EntitySystem component.Active = active; Dirty(component); } + + + /// + /// Copy constructor to copy initial fields from source to destination. + /// + /// Entity to which we copy properties + /// Component that contains relevant properties + public void CopyConstruct(EntityUid destUid, StepTriggerComponent srcStep) + { + var destTrigger = EntityManager.EnsureComponent(destUid); + destTrigger.Active = srcStep.Active; + destTrigger.IntersectRatio = srcStep.IntersectRatio; + destTrigger.RequiredTriggerSpeed = srcStep.RequiredTriggerSpeed; + } } [ByRefEvent] diff --git a/Content.Tests/Shared/DirectionRandomizerTest.cs b/Content.Tests/Shared/DirectionRandomizerTest.cs new file mode 100644 index 0000000000..595bdeb600 --- /dev/null +++ b/Content.Tests/Shared/DirectionRandomizerTest.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using Content.Shared; +using NUnit.Framework; +using Robust.Shared.Maths; +using Robust.UnitTesting; + +namespace Content.Tests.Shared; + +[TestFixture] +public sealed class DirectionRandomizerTest : RobustUnitTest +{ + [Test] + [TestCase(new[] + { + Direction.East, + Direction.NorthEast, + Direction.West, + Direction.NorthWest, + Direction.South, + Direction.SouthWest, + Direction.North, + Direction.SouthEast, + })] + [TestCase(new[] + { + Direction.East, + Direction.West, + Direction.South, + Direction.North, + })] + [TestCase(new[] + { + Direction.East, + Direction.West, + })] + public void TestRandomization(Direction[] x) + { + var set = new HashSet(x); + var randomizer = new Span(x); + randomizer.Shuffle(); + foreach (var direction in randomizer) + { + if (set.Contains(direction)) + { + set.Remove(direction); + } + else + { + // Asserts no double direction + Assert.Fail("Post randomization the enumerator had repeated direction"); + } + } + // Because of above foreach this asserts + // rand[1,2,3] - [1,2,3] == {} + // i.e. randomized set minus original set is empty + Assert.IsTrue(set.Count == 0, "Each element must appear once "); + + } +}