diff --git a/Content.Client/Atmos/AtmosDebugOverlay.cs b/Content.Client/Atmos/AtmosDebugOverlay.cs new file mode 100644 index 0000000000..5ce73a66a0 --- /dev/null +++ b/Content.Client/Atmos/AtmosDebugOverlay.cs @@ -0,0 +1,107 @@ +using Content.Client.GameObjects.EntitySystems; +using Content.Shared.GameObjects.EntitySystems.Atmos; +using Content.Shared.Atmos; +using Robust.Client.Graphics.ClientEye; +using Robust.Client.Graphics.Drawing; +using Robust.Client.Graphics.Overlays; +using Robust.Client.Interfaces.Graphics; +using Robust.Client.Interfaces.Graphics.ClientEye; +using Robust.Shared.GameObjects.Systems; +using Robust.Shared.Interfaces.Map; +using Robust.Shared.IoC; +using Robust.Shared.Maths; + +namespace Content.Client.Atmos +{ + public class AtmosDebugOverlay : Overlay + { + private readonly AtmosDebugOverlaySystem _atmosDebugOverlaySystem; + + [Dependency] private readonly IMapManager _mapManager = default!; + [Dependency] private readonly IEyeManager _eyeManager = default!; + [Dependency] private readonly IClyde _clyde = default!; + + public override OverlaySpace Space => OverlaySpace.WorldSpace; + + public AtmosDebugOverlay() : base(nameof(AtmosDebugOverlay)) + { + IoCManager.InjectDependencies(this); + + _atmosDebugOverlaySystem = EntitySystem.Get(); + } + + protected override void Draw(DrawingHandleBase handle, OverlaySpace overlay) + { + var drawHandle = (DrawingHandleWorld) handle; + + var mapId = _eyeManager.CurrentMap; + var eye = _eyeManager.CurrentEye; + + var worldBounds = Box2.CenteredAround(eye.Position.Position, + _clyde.ScreenSize / (float) EyeManager.PixelsPerMeter * eye.Zoom); + + // IF YOU ARE ABOUT TO INTRODUCE CHUNKING OR SOME OTHER OPTIMIZATION INTO THIS CODE: + // -- THINK! -- + // 1. "Is this going to make a critical atmos debugging tool harder to debug itself?" + // 2. "Is this going to do anything that could cause the atmos debugging tool to use resources, server-side or client-side, when nobody's using it?" + // 3. "Is this going to make it harder for atmos programmers to add data that may not be chunk-friendly into the atmos debugger?" + // Nanotrasen needs YOU! to avoid premature optimization in critical debugging tools - 20kdc + + foreach (var mapGrid in _mapManager.FindGridsIntersecting(mapId, worldBounds)) + { + if (!_atmosDebugOverlaySystem.HasData(mapGrid.Index)) + continue; + + var gridBounds = new Box2(mapGrid.WorldToLocal(worldBounds.BottomLeft), mapGrid.WorldToLocal(worldBounds.TopRight)); + + for (var pass = 0; pass < 3; pass++) + { + foreach (var tile in mapGrid.GetTilesIntersecting(gridBounds)) + { + var dataMaybeNull = _atmosDebugOverlaySystem.GetData(mapGrid.Index, tile.GridIndices); + if (dataMaybeNull != null) + { + var data = (SharedAtmosDebugOverlaySystem.AtmosDebugOverlayData) dataMaybeNull!; + if (pass == 0) + { + float total = 0; + foreach (float f in data.Moles) + { + total += f; + } + var interp = total / (Atmospherics.MolesCellStandard * 2); + var res = Color.InterpolateBetween(Color.Red, Color.Green, interp).WithAlpha(0.75f); + drawHandle.DrawRect(Box2.FromDimensions(mapGrid.LocalToWorld(new Vector2(tile.X, tile.Y)), new Vector2(1, 1)), res); + } + else if (pass == 1) + { + if (data.PressureDirection != AtmosDirection.Invalid) + { + var atmosAngle = data.PressureDirection.ToAngle(); + var atmosAngleOfs = atmosAngle.ToVec() * 0.4f; + var tileCentre = new Vector2(tile.X + 0.5f, tile.Y + 0.5f); + var basisA = mapGrid.LocalToWorld(tileCentre); + var basisB = mapGrid.LocalToWorld(tileCentre + atmosAngleOfs); + drawHandle.DrawLine(basisA, basisB, Color.Blue); + } + } + else if (pass == 2) + { + if (data.InExcitedGroup) + { + var tilePos = new Vector2(tile.X, tile.Y); + var basisA = mapGrid.LocalToWorld(tilePos); + var basisB = mapGrid.LocalToWorld(tilePos + new Vector2(1.0f, 1.0f)); + var basisC = mapGrid.LocalToWorld(tilePos + new Vector2(0.0f, 1.0f)); + var basisD = mapGrid.LocalToWorld(tilePos + new Vector2(1.0f, 0.0f)); + drawHandle.DrawLine(basisA, basisB, Color.Cyan); + drawHandle.DrawLine(basisC, basisD, Color.Cyan); + } + } + } + } + } + } + } + } +} diff --git a/Content.Client/GameObjects/EntitySystems/AtmosDebugOverlaySystem.cs b/Content.Client/GameObjects/EntitySystems/AtmosDebugOverlaySystem.cs new file mode 100644 index 0000000000..b6b80b22b7 --- /dev/null +++ b/Content.Client/GameObjects/EntitySystems/AtmosDebugOverlaySystem.cs @@ -0,0 +1,84 @@ +#nullable enable +using System; +using System.Collections.Generic; +using Content.Client.Atmos; +using Content.Shared.Atmos; +using Content.Shared.GameObjects.EntitySystems.Atmos; +using JetBrains.Annotations; +using Robust.Client.Graphics; +using Robust.Client.Interfaces.Graphics.Overlays; +using Robust.Client.Interfaces.ResourceManagement; +using Robust.Client.ResourceManagement; +using Robust.Client.Utility; +using Robust.Shared.Interfaces.Map; +using Robust.Shared.IoC; +using Robust.Shared.Map; +using Robust.Shared.Maths; +using Robust.Shared.Utility; + +namespace Content.Client.GameObjects.EntitySystems +{ + [UsedImplicitly] + internal sealed class AtmosDebugOverlaySystem : SharedAtmosDebugOverlaySystem + { + [Dependency] private readonly IMapManager _mapManager = default!; + [Dependency] private readonly IResourceCache _resourceCache = default!; + + private Dictionary _tileData = + new Dictionary(); + + private AtmosphereSystem _atmosphereSystem = default!; + + public override void Initialize() + { + base.Initialize(); + SubscribeNetworkEvent(HandleAtmosDebugOverlayMessage); + _mapManager.OnGridRemoved += OnGridRemoved; + + _atmosphereSystem = Get(); + + var overlayManager = IoCManager.Resolve(); + if(!overlayManager.HasOverlay(nameof(AtmosDebugOverlay))) + overlayManager.AddOverlay(new AtmosDebugOverlay()); + } + + private void HandleAtmosDebugOverlayMessage(AtmosDebugOverlayMessage message) + { + _tileData[message.GridId] = message; + } + + public override void Shutdown() + { + base.Shutdown(); + _mapManager.OnGridRemoved -= OnGridRemoved; + var overlayManager = IoCManager.Resolve(); + if(!overlayManager.HasOverlay(nameof(GasTileOverlay))) + overlayManager.RemoveOverlay(nameof(GasTileOverlay)); + } + + private void OnGridRemoved(GridId gridId) + { + if (_tileData.ContainsKey(gridId)) + { + _tileData.Remove(gridId); + } + } + + public bool HasData(GridId gridId) + { + return _tileData.ContainsKey(gridId); + } + + public AtmosDebugOverlayData? GetData(GridId gridIndex, MapIndices indices) + { + if (!_tileData.TryGetValue(gridIndex, out var srcMsg)) + return null; + + var relative = indices - srcMsg.BaseIdx; + if (relative.X < 0 || relative.Y < 0 || relative.X >= LocalViewRange || relative.Y >= LocalViewRange) + return null; + + return srcMsg.OverlayData[relative.X + (relative.Y * LocalViewRange)]; + } + } +} diff --git a/Content.Server/Atmos/AtmosCommands.cs b/Content.Server/Atmos/AtmosCommands.cs index c6990433d9..1b69ba5c80 100644 --- a/Content.Server/Atmos/AtmosCommands.cs +++ b/Content.Server/Atmos/AtmosCommands.cs @@ -2,6 +2,7 @@ using System; using Content.Server.GameObjects.Components.Atmos; using Content.Server.GameObjects.EntitySystems; +using Content.Server.GameObjects.EntitySystems.Atmos; using Content.Shared.Atmos; using Robust.Server.Interfaces.Console; using Robust.Server.Interfaces.Player; @@ -621,4 +622,26 @@ namespace Content.Server.Atmos shell.SendText(player, $"Removed {moles} moles of gas {gas} from {tiles} tiles."); } } -} + + public class ShowAtmos : IClientCommand + { + public string Command => "showatmos"; + public string Description => "Toggles seeing atmos debug overlay"; + public string Help => $"Usage: {Command}"; + + public void Execute(IConsoleShell shell, IPlayerSession? player, string[] args) + { + if (player == null) return; + var atmosDebug = EntitySystem.Get(); + if (atmosDebug.PlayerObservers.Contains(player)) + { + atmosDebug.PlayerObservers.Remove(player); + shell.SendText(player, $"Ok, disabled"); + } + else + { + atmosDebug.PlayerObservers.Add(player); + shell.SendText(player, $"Ok, enabled"); + } + } + }} diff --git a/Content.Server/Atmos/TileAtmosphere.cs b/Content.Server/Atmos/TileAtmosphere.cs index 38f5f6ffaf..1c05e2a552 100644 --- a/Content.Server/Atmos/TileAtmosphere.cs +++ b/Content.Server/Atmos/TileAtmosphere.cs @@ -93,6 +93,9 @@ namespace Content.Server.Atmos private AtmosDirection _pressureDirection; + // I'm assuming there's a good reason the original variable was made private, but this information is also important. + public AtmosDirection PressureDirectionForDebugOverlay => _pressureDirection; + [ViewVariables, UsedImplicitly] private int PressureDirectionInt => (int)_pressureDirection; diff --git a/Content.Server/GameObjects/EntitySystems/Atmos/AtmosDebugOverlaySystem.cs b/Content.Server/GameObjects/EntitySystems/Atmos/AtmosDebugOverlaySystem.cs new file mode 100644 index 0000000000..1187eb393d --- /dev/null +++ b/Content.Server/GameObjects/EntitySystems/Atmos/AtmosDebugOverlaySystem.cs @@ -0,0 +1,141 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using Content.Server.GameObjects.Components.Atmos; +using Content.Server.Atmos; +using Content.Shared.Atmos; +using Content.Shared.GameObjects.EntitySystems.Atmos; +using JetBrains.Annotations; +using Robust.Server.Interfaces.Player; +using Robust.Server.Player; +using Robust.Shared.Enums; +using Robust.Shared.Interfaces.Configuration; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Interfaces.Map; +using Robust.Shared.Interfaces.Timing; +using Robust.Shared.Map; +using Robust.Shared.Maths; +using Robust.Shared.Timing; + +namespace Content.Server.GameObjects.EntitySystems.Atmos +{ + [UsedImplicitly] + public sealed class AtmosDebugOverlaySystem : SharedAtmosDebugOverlaySystem + { + [Robust.Shared.IoC.Dependency] private readonly IGameTiming _gameTiming = default!; + [Robust.Shared.IoC.Dependency] private readonly IPlayerManager _playerManager = default!; + [Robust.Shared.IoC.Dependency] private readonly IEntityManager _entityManager = default!; + [Robust.Shared.IoC.Dependency] private readonly IMapManager _mapManager = default!; + [Robust.Shared.IoC.Dependency] private readonly IConfigurationManager _configManager = default!; + + /// + /// Players allowed to see the atmos debug overlay + /// + public HashSet PlayerObservers = new HashSet(); + + /// + /// Overlay update ticks per second. + /// + private float _updateCooldown; + + private AtmosphereSystem _atmosphereSystem = default!; + + public override void Initialize() + { + base.Initialize(); + + _atmosphereSystem = Get(); + _playerManager.PlayerStatusChanged += OnPlayerStatusChanged; + _configManager.RegisterCVar("net.atmosdbgoverlaytickrate", 3.0f); + } + + public override void Shutdown() + { + base.Shutdown(); + _playerManager.PlayerStatusChanged -= OnPlayerStatusChanged; + } + + private void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs e) + { + if (e.NewStatus != SessionStatus.InGame) + { + if (PlayerObservers.Contains(e.Session)) + { + PlayerObservers.Remove(e.Session); + } + + return; + } + } + + private AtmosDebugOverlayData ConvertTileToData(TileAtmosphere? tile) + { + var gases = new float[Atmospherics.TotalNumberOfGases]; + if (tile?.Air == null) + { + return new AtmosDebugOverlayData(0, gases, AtmosDirection.Invalid, false); + } + else + { + for (var i = 0; i < Atmospherics.TotalNumberOfGases; i++) + { + gases[i] = tile.Air.GetMoles(i); + } + return new AtmosDebugOverlayData(tile.Air.Temperature, gases, tile.PressureDirectionForDebugOverlay, tile.ExcitedGroup != null); + } + } + + public override void Update(float frameTime) + { + AccumulatedFrameTime += frameTime; + _updateCooldown = 1 / _configManager.GetCVar("net.atmosdbgoverlaytickrate"); + + if (AccumulatedFrameTime < _updateCooldown) + { + return; + } + + // This is the timer from GasTileOverlaySystem + AccumulatedFrameTime -= _updateCooldown; + + var currentTick = _gameTiming.CurTick; + + // Now we'll go through each player, then through each chunk in range of that player checking if the player is still in range + // If they are, check if they need the new data to send (i.e. if there's an overlay for the gas). + // Afterwards we reset all the chunk data for the next time we tick. + foreach (var session in PlayerObservers) + { + if (session.AttachedEntity == null) continue; + + var entity = session.AttachedEntity; + + var worldBounds = Box2.CenteredAround(entity.Transform.WorldPosition, + new Vector2(LocalViewRange, LocalViewRange)); + + foreach (var grid in _mapManager.FindGridsIntersecting(entity.Transform.MapID, worldBounds)) + { + if (!_entityManager.TryGetEntity(grid.GridEntityId, out var gridEnt)) continue; + + if (!gridEnt.TryGetComponent(out var gam)) continue; + + var entityTile = grid.GetTileRef(entity.Transform.Coordinates).GridIndices; + var baseTile = new MapIndices(entityTile.X - (LocalViewRange / 2), entityTile.Y - (LocalViewRange / 2)); + var debugOverlayContent = new AtmosDebugOverlayData[LocalViewRange * LocalViewRange]; + + var index = 0; + for (var y = 0; y < LocalViewRange; y++) + { + for (var x = 0; x < LocalViewRange; x++) + { + var mapIndices = new MapIndices(baseTile.X + x, baseTile.Y + y); + debugOverlayContent[index++] = ConvertTileToData(gam.GetTile(mapIndices)); + } + } + + RaiseNetworkEvent(new AtmosDebugOverlayMessage(grid.Index, baseTile, debugOverlayContent), session.ConnectedClient); + } + } + } + } +} diff --git a/Content.Shared/GameObjects/EntitySystems/Atmos/SharedAtmosDebugOverlaySystem.cs b/Content.Shared/GameObjects/EntitySystems/Atmos/SharedAtmosDebugOverlaySystem.cs new file mode 100644 index 0000000000..ccf802dd35 --- /dev/null +++ b/Content.Shared/GameObjects/EntitySystems/Atmos/SharedAtmosDebugOverlaySystem.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using Robust.Shared.GameObjects; +using Robust.Shared.GameObjects.Systems; +using Robust.Shared.Map; +using Robust.Shared.Serialization; +using Robust.Shared.Utility; +using Content.Shared.Atmos; + +namespace Content.Shared.GameObjects.EntitySystems.Atmos +{ + public abstract class SharedAtmosDebugOverlaySystem : EntitySystem + { + // Keep in mind, this system is hilariously unoptimized. The goal here is to provide accurate debug data. + public const int LocalViewRange = 16; + protected float AccumulatedFrameTime; + + [Serializable, NetSerializable] + public readonly struct AtmosDebugOverlayData + { + public readonly float Temperature; + public readonly float[] Moles; + public readonly AtmosDirection PressureDirection; + public readonly bool InExcitedGroup; + + public AtmosDebugOverlayData(float temperature, float[] moles, AtmosDirection pressureDirection, bool inExcited) + { + Temperature = temperature; + Moles = moles; + PressureDirection = pressureDirection; + InExcitedGroup = inExcited; + } + } + + /// + /// Invalid tiles for the gas overlay. + /// No point re-sending every tile if only a subset might have been updated. + /// + [Serializable, NetSerializable] + public sealed class AtmosDebugOverlayMessage : EntitySystemMessage + { + public GridId GridId { get; } + + public MapIndices BaseIdx { get; } + // LocalViewRange*LocalViewRange + public AtmosDebugOverlayData[] OverlayData { get; } + + public AtmosDebugOverlayMessage(GridId gridIndices, MapIndices baseIdx, AtmosDebugOverlayData[] overlayData) + { + GridId = gridIndices; + BaseIdx = baseIdx; + OverlayData = overlayData; + } + } + } +} diff --git a/Resources/Groups/groups.yml b/Resources/Groups/groups.yml index 9817a40c29..25cc70561d 100644 --- a/Resources/Groups/groups.yml +++ b/Resources/Groups/groups.yml @@ -195,6 +195,7 @@ - settemp - setatmostemp - deletegas + - showatmos - tilewalls - events - destroymechanism