diff --git a/Content.Client/Atmos/Overlays/GasTileDangerousTemperatureOverlay.cs b/Content.Client/Atmos/Overlays/GasTileDangerousTemperatureOverlay.cs
new file mode 100644
index 00000000000..69e251d721f
--- /dev/null
+++ b/Content.Client/Atmos/Overlays/GasTileDangerousTemperatureOverlay.cs
@@ -0,0 +1,253 @@
+using Content.Client.Atmos.EntitySystems;
+using Content.Shared.Atmos;
+using Content.Shared.Atmos.Components;
+using Content.Shared.Atmos.EntitySystems;
+using Robust.Client.Graphics;
+using Robust.Shared.Enums;
+using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
+using System.Numerics;
+
+namespace Content.Client.Atmos.Overlays;
+
+///
+/// Renders a thermal heatmap overlay for gas tiles, used for equipment like thermal glasses.
+/// ///
+public sealed class GasTileDangerousTemperatureOverlay : Overlay
+{
+ public override bool RequestScreenTexture { get; set; } = false;
+
+ [Dependency] private readonly IEntityManager _entManager = default!;
+ [Dependency] private readonly IMapManager _mapManager = default!;
+ [Dependency] private readonly IClyde _clyde = default!;
+
+ private GasTileOverlaySystem? _gasTileOverlay;
+ private readonly SharedTransformSystem _xformSys;
+ private EntityQuery _overlayQuery;
+
+ private IRenderTexture? _temperatureTarget;
+
+ // Cache used to transform ThermalByte into Color for overlay
+ private readonly Color[] _colorCache = new Color[256];
+
+ public override OverlaySpace Space => OverlaySpace.WorldSpaceBelowFOV;
+
+ public GasTileDangerousTemperatureOverlay()
+ {
+ IoCManager.InjectDependencies(this);
+ _xformSys = _entManager.System();
+
+ _overlayQuery = _entManager.GetEntityQuery();
+
+ for (byte i = 0; i <= ThermalByte.TempResolution; i++)
+ {
+ _colorCache[i] = PreCalculateColor(i);
+ }
+
+ _colorCache[ThermalByte.StateVacuum] = Color.Teal;
+ _colorCache[ThermalByte.StateVacuum].A = 0.6f;
+ _colorCache[ThermalByte.AtmosImpossible] = Color.Transparent;
+
+#if DEBUG // This shouldn't happend so tell me if you see this LimeGreen on the screen
+ _colorCache[ThermalByte.ReservedFuture0] = Color.LimeGreen;
+ _colorCache[ThermalByte.ReservedFuture1] = Color.LimeGreen;
+ _colorCache[ThermalByte.ReservedFuture2] = Color.LimeGreen;
+#else
+ _colorCache[ThermalByte.ReservedFuture0] = Color.Transparent;
+ _colorCache[ThermalByte.ReservedFuture1] = Color.Transparent;
+ _colorCache[ThermalByte.ReservedFuture2] = Color.Transparent;
+#endif
+ }
+
+
+ ///
+ /// Used for Calculating onscreen color from ThermalByte core value
+ /// ///
+ private static Color PreCalculateColor(byte byteTemp)
+ {
+ // Color Thresholds in Kelvin
+ // -150 C
+ const float deepFreezeK = 123.15f;
+ // -50 C
+ const float freezeStartK = 223.15f;
+ // 0 C
+ const float waterFreezeK = 273.15f;
+ // 50 C
+ const float heatStartK = 323.15f;
+ // 100 C
+ const float waterBoilK = 373.15f;
+ // 300 C
+ const float superHeatK = 573.15f;
+
+ var tempK = byteTemp * ThermalByte.TempDegreeResolution;
+
+ // Neutral Zone Check (0C to 50C)
+ // If between 273.15K and 323.15K, it's transparent.
+ if (tempK >= waterFreezeK && tempK < heatStartK)
+ {
+ return Color.Transparent;
+ }
+
+ Color resultingColor;
+
+ switch (tempK)
+ {
+ case < deepFreezeK:
+ resultingColor = Color.FromHex("#330066");
+ resultingColor.A = 0.7f;
+ break;
+ case < freezeStartK:
+ // Interpolate Deep Purple -> Blue
+ // Range: 123.15 to 223.15 (Span: 100)
+ resultingColor = Color.InterpolateBetween(
+ Color.FromHex("#330066"),
+ Color.Blue,
+ (tempK - deepFreezeK) * 0.01f);
+ resultingColor.A = 0.6f;
+ break;
+ case < waterFreezeK:
+ // Interpolate Blue -> Transparent
+ // Range: 223.15 to 273.15 (Span: 50)
+
+ resultingColor = Color.InterpolateBetween(
+ new Color(Color.Blue.R, Color.Blue.G, Color.Blue.B, 0.6f),
+ new Color(Color.Blue.R, Color.Blue.G, Color.Blue.B, 0.2f),
+ (tempK - freezeStartK) * 0.02f);
+ break;
+ case < waterBoilK:
+ // Interpolate Transparent -> Yellow
+ // Range: 323.15 to 373.15 (Span: 50)
+
+ resultingColor = Color.InterpolateBetween(
+ new Color(Color.Yellow.R, Color.Yellow.G, Color.Yellow.B, 0.2f),
+ new Color(Color.Yellow.R, Color.Yellow.G, Color.Yellow.B, 0.6f),
+ (tempK - heatStartK) * 0.02f);
+ break;
+ case < superHeatK:
+ // Interpolate Yellow -> Red
+ // Range: 373.15 to 573.15 (Span: 200)
+ resultingColor = Color.InterpolateBetween(
+ Color.Yellow,
+ Color.Red,
+ (tempK - waterBoilK) * 0.005f);
+ resultingColor.A = 0.6f;
+ break;
+ default:
+ resultingColor = Color.DarkRed;
+ resultingColor.A = 0.7f;
+ break;
+ }
+
+ return resultingColor;
+ }
+
+ protected override bool BeforeDraw(in OverlayDrawArgs args)
+ {
+ if (args.MapId == MapId.Nullspace)
+ return false;
+
+ _gasTileOverlay ??= _entManager.System();
+ if (_gasTileOverlay == null)
+ return false;
+
+ var target = args.Viewport.RenderTarget;
+
+ if (_temperatureTarget?.Texture.Size != target.Size)
+ {
+ _temperatureTarget?.Dispose();
+ _temperatureTarget = _clyde.CreateRenderTarget(
+ target.Size,
+ new RenderTargetFormatParameters(RenderTargetColorFormat.Rgba8Srgb),
+ name: nameof(GasTileDangerousTemperatureOverlay));
+ }
+
+ var drawHandle = args.WorldHandle;
+ var worldBounds = args.WorldBounds;
+ var worldAABB = args.WorldAABB;
+ var mapId = args.MapId;
+ var worldToViewportLocal = args.Viewport.GetWorldToLocalMatrix();
+
+ var anyGasDrawn = false;
+ List> grids = new();
+
+ drawHandle.RenderInRenderTarget(_temperatureTarget,
+ () =>
+ {
+ grids.Clear();
+ _mapManager.FindGridsIntersecting(mapId, worldAABB, ref grids);
+
+ foreach (var grid in grids)
+ {
+ if (!_overlayQuery.TryGetComponent(grid.Owner, out var comp))
+ continue;
+
+ var gridTileSizeVec = grid.Comp.TileSizeVector;
+ var gridTileCenterVec = grid.Comp.TileSizeHalfVector;
+ var gridEntToWorld = _xformSys.GetWorldMatrix(grid.Owner);
+ var gridEntToViewportLocal = gridEntToWorld * worldToViewportLocal;
+
+ drawHandle.SetTransform(gridEntToViewportLocal);
+
+ var worldToGridLocal = _xformSys.GetInvWorldMatrix(grid.Owner);
+ var floatBounds = worldToGridLocal.TransformBox(worldBounds).Enlarged(grid.Comp.TileSize);
+
+ var localBounds = new Box2i(
+ (int)MathF.Floor(floatBounds.Left),
+ (int)MathF.Floor(floatBounds.Bottom),
+ (int)MathF.Ceiling(floatBounds.Right),
+ (int)MathF.Ceiling(floatBounds.Top));
+
+ foreach (var chunk in comp.Chunks.Values)
+ {
+ var enumerator = new GasChunkEnumerator(chunk);
+ while (enumerator.MoveNext(out var tileGas))
+ {
+ var tilePosition = chunk.Origin + (enumerator.X, enumerator.Y);
+ if (!localBounds.Contains(tilePosition))
+ continue;
+
+ var gasColor = _colorCache[tileGas.ByteGasTemperature.Value];
+
+ if (gasColor.A <= 0f)
+ continue;
+
+ anyGasDrawn = true;
+
+ drawHandle.DrawRect(
+ Box2.CenteredAround(tilePosition + gridTileCenterVec, gridTileSizeVec),
+ gasColor
+ );
+ }
+ }
+ }
+ },
+ new Color(0, 0, 0, 0));
+
+ drawHandle.SetTransform(Matrix3x2.Identity);
+
+ if (!anyGasDrawn)
+ {
+ _temperatureTarget?.Dispose();
+ _temperatureTarget = null;
+ return false;
+ }
+
+ return true;
+ }
+
+ protected override void Draw(in OverlayDrawArgs args)
+ {
+ if (_temperatureTarget is null)
+ return;
+
+ args.WorldHandle.DrawTextureRect(_temperatureTarget.Texture, args.WorldBounds);
+ args.WorldHandle.SetTransform(Matrix3x2.Identity);
+ }
+
+ protected override void DisposeBehavior()
+ {
+ _temperatureTarget?.Dispose();
+ _temperatureTarget = null;
+ base.DisposeBehavior();
+ }
+}
diff --git a/Content.Client/Overlays/ThermalSightOverlaySystem.cs b/Content.Client/Overlays/ThermalSightOverlaySystem.cs
new file mode 100644
index 00000000000..bd4d21d3dcc
--- /dev/null
+++ b/Content.Client/Overlays/ThermalSightOverlaySystem.cs
@@ -0,0 +1,34 @@
+using Content.Client.Atmos.Overlays;
+using Content.Shared.Inventory.Events;
+using Content.Shared.Overlays;
+using Robust.Client.Graphics;
+
+namespace Content.Client.Overlays;
+
+public sealed partial class ThermalSightOverlaySystem : EquipmentHudSystem
+{
+ [Dependency] private readonly IOverlayManager _overlayMan = default!;
+
+ private GasTileDangerousTemperatureOverlay _temperatureOverlay = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ _temperatureOverlay = new();
+ }
+
+ protected override void UpdateInternal(RefreshEquipmentHudEvent component)
+ {
+ base.UpdateInternal(component);
+
+ _overlayMan.AddOverlay(_temperatureOverlay);
+ }
+
+ protected override void DeactivateInternal()
+ {
+ base.DeactivateInternal();
+
+ _overlayMan.RemoveOverlay(_temperatureOverlay);
+ }
+}
diff --git a/Content.IntegrationTests/Tests/Atmos/SharedGasTileOverlaySystemTest.cs b/Content.IntegrationTests/Tests/Atmos/SharedGasTileOverlaySystemTest.cs
new file mode 100644
index 00000000000..4a8e1d1bac0
--- /dev/null
+++ b/Content.IntegrationTests/Tests/Atmos/SharedGasTileOverlaySystemTest.cs
@@ -0,0 +1,103 @@
+using Content.Server.Atmos.EntitySystems;
+using Content.Shared.Atmos;
+using Content.Shared.Atmos.Components;
+using Content.Shared.Atmos.EntitySystems;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Map;
+using Robust.Shared.Maths;
+using Robust.Shared.Utility;
+using System.Linq;
+using System.Numerics;
+
+namespace Content.IntegrationTests.Tests.Atmos;
+
+///
+/// GasTileOverlay is being tested here
+///
+public sealed class GasTileOverlayTemperatureNetworkingTest : AtmosTest
+{
+ protected override ResPath? TestMapPath => new("Maps/Test/Atmospherics/DeltaPressure/deltapressuretest.yml");
+
+ [Test]
+ public async Task TestGasOverlayDataSync()
+ {
+ var sMapSys = Server.System();
+
+ var gridComp = ProcessEnt.Comp3;
+ var gridNetEnt = Server.EntMan.GetNetEntity(ProcessEnt);
+
+ var gridCoords = new EntityCoordinates(ProcessEnt, Vector2.Zero);
+ var tileIndices = sMapSys.TileIndicesFor(ProcessEnt, gridComp, gridCoords);
+ var mixture = SAtmos.GetTileMixture(ProcessEnt, null, tileIndices, true);
+
+ // Get data for client side.
+ var cGridEnt = CEntMan.GetEntity(gridNetEnt);
+ Assert.That(CEntMan.TryGetComponent(cGridEnt, out var cOverlay),
+ "Client grid is missing GasTileOverlayComponent");
+
+ // Check if the server actually sent the gas chunks
+ Assert.That(cOverlay, Is.Not.Null, "Gas overlay is null on the client.");
+ Assert.That(cOverlay.Chunks, Is.Not.Empty, "Gas overlay chunks are empty on the client.");
+
+ //Start real tests
+ await InjectHotPlasma(ProcessEnt, tileIndices, mixture, 400f);
+
+ await CheckForInjectedGas(cOverlay, tileIndices, 400f);
+
+ await InjectHotPlasma(ProcessEnt, tileIndices, mixture, 800f + ThermalByte.TempDegreeResolution - 1); // Rounding test
+
+ await CheckForInjectedGas(cOverlay, tileIndices, 800f);
+
+ await InjectHotPlasma(ProcessEnt, tileIndices, mixture, ThermalByte.TempMaximum + 200f); // This one hits max temperature
+
+ await CheckForInjectedGas(cOverlay, tileIndices, ThermalByte.TempMaximum);
+
+ await InjectHotPlasma(ProcessEnt, tileIndices, mixture, ThermalByte.TempMinimum);
+ await InjectHotPlasma(ProcessEnt, tileIndices, mixture, ThermalByte.TempMinimum + (ThermalByte.TempDegreeResolution * 2) - 1); // Test the networking optimisation, this should not be networked yet
+
+ await CheckForInjectedGas(cOverlay, tileIndices, ThermalByte.TempMinimum);
+
+ await InjectHotPlasma(ProcessEnt, tileIndices, mixture, ThermalByte.TempMinimum + (ThermalByte.TempDegreeResolution * 2)); // This should
+
+ await CheckForInjectedGas(cOverlay, tileIndices, ThermalByte.TempMinimum + (ThermalByte.TempDegreeResolution * 2));
+ }
+
+ private async Task CheckForInjectedGas(GasTileOverlayComponent overlay, Vector2i indices, float expectedTemp)
+ {
+ await Client.WaitPost(() =>
+ {
+ var chunkIndices = SharedGasTileOverlaySystem.GetGasChunkIndices(indices);
+
+ Assert.That(overlay.Chunks.TryGetValue(chunkIndices, out var chunk), "Chunk not found");
+ Assert.That(chunk, Is.Not.Null, "Chunk not found");
+
+ // Calculate the exact index in the TileData array
+ var localX = MathHelper.Mod(indices.X, SharedGasTileOverlaySystem.ChunkSize);
+ var localY = MathHelper.Mod(indices.Y, SharedGasTileOverlaySystem.ChunkSize);
+ int tileIndex = localX + localY * SharedGasTileOverlaySystem.ChunkSize;
+
+ var tile = chunk.TileData[tileIndex];
+ tile.ByteGasTemperature.TryGetTemperature(out var actualTemp);
+
+ Assert.That(actualTemp, Is.EqualTo(expectedTemp).Within(0.01f), $"Tile at {indices} had wrong temperature!");
+ });
+ }
+
+ private async Task InjectHotPlasma(EntityUid gridEnt, Vector2i tileIndices, GasMixture mixture, float temperature)
+ {
+ //Server makes atmos
+ await Server.WaitPost(() =>
+ {
+ if (mixture != null)
+ {
+ mixture.Clear();
+ mixture.AdjustMoles(Gas.Plasma, 100f); // Inject hot plasma
+ mixture.Temperature = temperature;
+ SAtmos.InvalidateVisuals(gridEnt, tileIndices);
+ }
+ });
+
+ await RunTicks(60);
+ await Task.WhenAll(Client.WaitIdleAsync(), Server.WaitIdleAsync());
+ }
+}
diff --git a/Content.Server/Atmos/EntitySystems/GasTileOverlaySystem.cs b/Content.Server/Atmos/EntitySystems/GasTileOverlaySystem.cs
index 4882e93d230..78ebba8e97c 100644
--- a/Content.Server/Atmos/EntitySystems/GasTileOverlaySystem.cs
+++ b/Content.Server/Atmos/EntitySystems/GasTileOverlaySystem.cs
@@ -1,6 +1,3 @@
-using System.Linq;
-using System.Runtime.CompilerServices;
-using System.Threading.Tasks;
using Content.Server.Atmos.Components;
using Content.Shared.Atmos;
using Content.Shared.Atmos.Components;
@@ -13,7 +10,6 @@ using JetBrains.Annotations;
using Microsoft.Extensions.ObjectPool;
using Robust.Server.Player;
using Robust.Shared;
-using Robust.Shared.Configuration;
using Robust.Shared.Enums;
using Robust.Shared.Map;
using Robust.Shared.Map.Components;
@@ -21,6 +17,7 @@ using Robust.Shared.Player;
using Robust.Shared.Threading;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
+using System.Runtime.CompilerServices;
// ReSharper disable once RedundantUsingDirective
@@ -32,7 +29,6 @@ namespace Content.Server.Atmos.EntitySystems
[Robust.Shared.IoC.Dependency] private readonly IGameTiming _gameTiming = default!;
[Robust.Shared.IoC.Dependency] private readonly IPlayerManager _playerManager = default!;
[Robust.Shared.IoC.Dependency] private readonly IMapManager _mapManager = default!;
- [Robust.Shared.IoC.Dependency] private readonly IConfigurationManager _confMan = default!;
[Robust.Shared.IoC.Dependency] private readonly IParallelManager _parMan = default!;
[Robust.Shared.IoC.Dependency] private readonly AtmosphereSystem _atmosphereSystem = default!;
[Robust.Shared.IoC.Dependency] private readonly ChunkingSystem _chunkingSys = default!;
@@ -85,9 +81,8 @@ namespace Content.Server.Atmos.EntitySystems
};
_playerManager.PlayerStatusChanged += OnPlayerStatusChanged;
- Subs.CVar(_confMan, CCVars.NetGasOverlayTickRate, UpdateTickRate, true);
- Subs.CVar(_confMan, CCVars.GasOverlayThresholds, UpdateThresholds, true);
- Subs.CVar(_confMan, CVars.NetPVS, OnPvsToggle, true);
+
+ InitializeCVars();
SubscribeLocalEvent(Reset);
SubscribeLocalEvent(OnStartup);
@@ -175,7 +170,16 @@ namespace Content.Server.Atmos.EntitySystems
public GasOverlayData GetOverlayData(GasMixture? mixture)
{
- var data = new GasOverlayData(0, new byte[VisibleGasId.Length]);
+ ThermalByte byteTemp;
+ if (mixture == null)
+ {
+ byteTemp = new();
+ byteTemp.SetVacuum();
+ }
+ else
+ byteTemp = new(mixture.Temperature);
+
+ var data = new GasOverlayData(0, new byte[VisibleGasId.Length], byteTemp);
for (var i = 0; i < VisibleGasId.Length; i++)
{
@@ -215,15 +219,27 @@ namespace Content.Server.Atmos.EntitySystems
}
var changed = false;
+
+ ThermalByte newByteTemp = new();
+
+ if (tile.Hotspot.Valid)
+ newByteTemp.SetTemperature(tile.Hotspot.Temperature);
+ else if (!tile.Space && tile.Air?.TotalMoles <= 5f)
+ newByteTemp.SetVacuum();
+ else if (!tile.Space && tile.Air != null)
+ newByteTemp = new(tile.Air.Temperature);
+
if (oldData.Equals(default))
{
changed = true;
- oldData = new GasOverlayData(tile.Hotspot.State, new byte[VisibleGasId.Length]);
+ oldData = new GasOverlayData(tile.Hotspot.State, new byte[VisibleGasId.Length], newByteTemp);
}
- else if (oldData.FireState != tile.Hotspot.State)
+ else if (oldData.FireState != tile.Hotspot.State ||
+ Math.Abs(oldData.ByteGasTemperature.Value - newByteTemp.Value) > 1 || // Dirty Temperature when there is more then 1 byte difference. That should measure up to minimum 4 degreese difference, 6 degreese on average.
+ (oldData.ByteGasTemperature.Value != newByteTemp.Value && newByteTemp.Value > ThermalByte.TempResolution)) // change of special ThermalByte value
{
changed = true;
- oldData = new GasOverlayData(tile.Hotspot.State, oldData.Opacity);
+ oldData = new GasOverlayData(tile.Hotspot.State, oldData.Opacity, newByteTemp);
}
if (tile is {Air: not null, NoGridTile: false})
@@ -465,5 +481,12 @@ namespace Content.Server.Atmos.EntitySystems
}
#endregion
+
+ private void InitializeCVars()
+ {
+ Subs.CVar(ConfMan, CCVars.NetGasOverlayTickRate, UpdateTickRate, true);
+ Subs.CVar(ConfMan, CCVars.GasOverlayThresholds, UpdateThresholds, true);
+ Subs.CVar(ConfMan, CVars.NetPVS, OnPvsToggle, true);
+ }
}
}
diff --git a/Content.Shared/Atmos/EntitySystems/SharedGasTileOverlaySystem.cs b/Content.Shared/Atmos/EntitySystems/SharedGasTileOverlaySystem.cs
index d831616355b..23ea8fa8fa7 100644
--- a/Content.Shared/Atmos/EntitySystems/SharedGasTileOverlaySystem.cs
+++ b/Content.Shared/Atmos/EntitySystems/SharedGasTileOverlaySystem.cs
@@ -1,114 +1,257 @@
using Content.Shared.Atmos.Components;
-using Content.Shared.Atmos.Prototypes;
+using Robust.Shared.Configuration;
using Robust.Shared.GameStates;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
-namespace Content.Shared.Atmos.EntitySystems
-{
- public abstract class SharedGasTileOverlaySystem : EntitySystem
- {
- public const byte ChunkSize = 8;
- protected float AccumulatedFrameTime;
- protected bool PvsEnabled;
+namespace Content.Shared.Atmos.EntitySystems;
- [Dependency] protected readonly IPrototypeManager ProtoMan = default!;
- [Dependency] private readonly SharedAtmosphereSystem _atmosphere = default!;
+public abstract class SharedGasTileOverlaySystem : EntitySystem
+{
+ public const byte ChunkSize = 8;
+ protected float AccumulatedFrameTime;
+ protected bool PvsEnabled;
+
+ [Dependency] protected readonly IPrototypeManager ProtoMan = default!;
+ [Dependency] protected readonly IConfigurationManager ConfMan = default!;
+ [Dependency] private readonly SharedAtmosphereSystem _atmosphere = default!;
+
+ ///
+ /// array of the ids of all visible gases.
+ ///
+ public int[] VisibleGasId = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent(OnGetState);
+
+ List visibleGases = new();
+
+ for (var i = 0; i < Atmospherics.TotalNumberOfGases; i++)
+ {
+ var gasPrototype = _atmosphere.GetGas(i);
+ if (!string.IsNullOrEmpty(gasPrototype.GasOverlayTexture) ||
+ (!string.IsNullOrEmpty(gasPrototype.GasOverlaySprite) && !string.IsNullOrEmpty(gasPrototype.GasOverlayState)))
+ visibleGases.Add(i);
+ }
+ VisibleGasId = visibleGases.ToArray();
+ }
+
+ private void OnGetState(EntityUid uid, GasTileOverlayComponent component, ref ComponentGetState args)
+ {
+ if (PvsEnabled && !args.ReplayState)
+ return;
+
+ // Should this be a full component state or a delta-state?
+ if (args.FromTick <= component.CreationTick || args.FromTick <= component.ForceTick)
+ {
+ args.State = new GasTileOverlayState(component.Chunks);
+ return;
+ }
+
+ var data = new Dictionary();
+ foreach (var (index, chunk) in component.Chunks)
+ {
+ if (chunk.LastUpdate >= args.FromTick)
+ data[index] = chunk;
+ }
+
+ args.State = new GasTileOverlayDeltaState(data, new(component.Chunks.Keys));
+ }
+
+ public static Vector2i GetGasChunkIndices(Vector2i indices)
+ {
+ return new Vector2i((int)MathF.Floor((float)indices.X / ChunkSize), (int)MathF.Floor((float)indices.Y / ChunkSize));
+ }
+
+ [Serializable, NetSerializable]
+ public readonly struct GasOverlayData : IEquatable
+ {
+ [ViewVariables] public readonly byte FireState;
+ [ViewVariables] public readonly byte[] Opacity;
+ // TODO change fire color based on ByteTemp
///
- /// array of the ids of all visible gases.
+ /// Network-synced air temperature, compressed to a single byte per tile for bandwidth optimization.
+ /// Note: Values are approximate and may deviate even ~10°C from the precise server side only temperature.
///
- public int[] VisibleGasId = default!;
+ [ViewVariables]
+ public readonly ThermalByte ByteGasTemperature;
- public override void Initialize()
+
+ public GasOverlayData(byte fireState, byte[] opacity, ThermalByte byteTemp)
{
- base.Initialize();
- SubscribeLocalEvent(OnGetState);
-
- List visibleGases = new();
-
- for (var i = 0; i < Atmospherics.TotalNumberOfGases; i++)
- {
- var gasPrototype = _atmosphere.GetGas(i);
- if (!string.IsNullOrEmpty(gasPrototype.GasOverlayTexture) || !string.IsNullOrEmpty(gasPrototype.GasOverlaySprite) && !string.IsNullOrEmpty(gasPrototype.GasOverlayState))
- visibleGases.Add(i);
- }
-
- VisibleGasId = visibleGases.ToArray();
+ FireState = fireState;
+ Opacity = opacity;
+ ByteGasTemperature = byteTemp;
}
- private void OnGetState(EntityUid uid, GasTileOverlayComponent component, ref ComponentGetState args)
+ public bool Equals(GasOverlayData other)
{
- if (PvsEnabled && !args.ReplayState)
- return;
+ if (FireState != other.FireState)
+ return false;
- // Should this be a full component state or a delta-state?
- if (args.FromTick <= component.CreationTick || args.FromTick <= component.ForceTick)
+ if (Opacity?.Length != other.Opacity?.Length)
+ return false;
+
+ if (Opacity != null && other.Opacity != null)
{
- args.State = new GasTileOverlayState(component.Chunks);
- return;
- }
-
- var data = new Dictionary();
- foreach (var (index, chunk) in component.Chunks)
- {
- if (chunk.LastUpdate >= args.FromTick)
- data[index] = chunk;
- }
-
- args.State = new GasTileOverlayDeltaState(data, new(component.Chunks.Keys));
- }
-
- public static Vector2i GetGasChunkIndices(Vector2i indices)
- {
- return new((int) MathF.Floor((float) indices.X / ChunkSize), (int) MathF.Floor((float) indices.Y / ChunkSize));
- }
-
- [Serializable, NetSerializable]
- public readonly struct GasOverlayData : IEquatable
- {
- [ViewVariables]
- public readonly byte FireState;
-
- [ViewVariables]
- public readonly byte[] Opacity;
-
- // TODO change fire color based on temps
- // But also: dont dirty on a 0.01 kelvin change in temperatures.
- // Either have a temp tolerance, or map temperature -> byte levels
-
- public GasOverlayData(byte fireState, byte[] opacity)
- {
- FireState = fireState;
- Opacity = opacity;
- }
-
- public bool Equals(GasOverlayData other)
- {
- if (FireState != other.FireState)
- return false;
-
- if (Opacity?.Length != other.Opacity?.Length)
- return false;
-
- if (Opacity != null && other.Opacity != null)
+ for (var i = 0; i < Opacity.Length; i++)
{
- for (var i = 0; i < Opacity.Length; i++)
- {
- if (Opacity[i] != other.Opacity[i])
- return false;
- }
+ if (Opacity[i] != other.Opacity[i])
+ return false;
}
-
- return true;
}
- }
- [Serializable, NetSerializable]
- public sealed class GasOverlayUpdateEvent : EntityEventArgs
- {
- public Dictionary> UpdatedChunks = new();
- public Dictionary> RemovedChunks = new();
+ if (ByteGasTemperature != other.ByteGasTemperature)
+ return false;
+
+ return true;
}
}
+
+ [Serializable, NetSerializable]
+ public sealed class GasOverlayUpdateEvent : EntityEventArgs
+ {
+ public Dictionary> UpdatedChunks = new();
+ public Dictionary> RemovedChunks = new();
+ }
+}
+
+///
+/// Struct for networking gas temperatures to all clients using a single struct(byte) per tile.
+///
+///
+///
+/// This struct compresses the gas temperature into a 1-byte value (0-255).
+/// It clamps the temperature to a maximum of 1000K and divides it by 4, creating a range of 0-250.
+/// This provides a resolution of 4 degrees Kelvin.
+///
+///
+/// The remaining bytes are used as special flags:
+///
+/// - 255: Represents a Wall (block cannot hold atmosphere).
+/// - 254: Represents a Vacuum.
+/// - 251-253: Reserved for future use.
+///
+///
+///
+/// Dirtying Logic: The value is only dirtied and networked if the difference between the
+/// networked byte and the real atmosphere byte is greater than 1. This prevents network spam
+/// from minor temperature fluctuations (e.g., heating from 1K to 8K will not trigger an update,
+/// but hitting 9K moves the byte index enough to sync).
+///
+///
+/// Currently, the conversion is linear. Future improvements might involve a quadratic scale
+/// or pre-defined resolution points to offer higher precision at room temperatures
+/// and lower precision at extreme temperatures (1000K).
+///
+///
+[Serializable, NetSerializable]
+public struct ThermalByte : IEquatable
+{
+ public const float TempMinimum = 0f;
+ public const float TempMaximum = 1000f;
+ public const int TempResolution = 250;
+
+ public const byte ReservedFuture0 = 251;
+ public const byte ReservedFuture1 = 252;
+ public const byte ReservedFuture2 = 253;
+ public const byte StateVacuum = 254;
+ public const byte AtmosImpossible = 255;
+
+ public const float TempDegreeResolution = (TempMaximum - TempMinimum) / TempResolution;
+ public const float TempToByteFactor = TempResolution / (TempMaximum - TempMinimum);
+
+ private byte _coreValue;
+
+ public ThermalByte(float temperatureKelvin)
+ {
+ SetTemperature(temperatureKelvin);
+ }
+
+ public ThermalByte()
+ {
+ _coreValue = AtmosImpossible;
+ }
+
+ ///
+ /// Set temperature of air in this in Kelvin.
+ ///
+ public void SetTemperature(float temperatureKelvin)
+ {
+ var clampedTemp = Math.Clamp(temperatureKelvin, TempMinimum, TempMaximum);
+ _coreValue = (byte)((clampedTemp - TempMinimum) * TempResolution / (TempMaximum - TempMinimum));
+ }
+
+ public void SetAtmosIsImpossible()
+ {
+ _coreValue = AtmosImpossible;
+ }
+
+ public void SetVacuum()
+ {
+ _coreValue = StateVacuum;
+ }
+
+ public bool IsAtmosImpossible => _coreValue == AtmosImpossible; // Cold space, solid walls
+ public bool IsVacuum => _coreValue == StateVacuum;
+ public byte Value => _coreValue;
+
+ ///
+ /// Attempts to get the air temperature in Kelvin.
+ ///
+ /// The temperature in Kelvin, if the tile has a valid temperature.
+ ///
+ /// If true and the tile is a vacuum, will be set to
+ /// and the method will return .
+ ///
+ ///
+ /// if the tile contains a valid temperature (including vacuum if is set);
+ /// otherwise (e.g., walls).
+ ///
+ public readonly bool TryGetTemperature(out float temperature, bool onVacuumReturnTcmb = true)
+ {
+ switch (_coreValue)
+ {
+ case AtmosImpossible:
+ temperature = 0f;
+ return false;
+ case StateVacuum when onVacuumReturnTcmb:
+ temperature = Atmospherics.TCMB;
+ return true;
+ case StateVacuum:
+ temperature = 0f;
+ return false;
+ default:
+ temperature = (_coreValue * TempDegreeResolution) + TempMinimum;
+ return true;
+ }
+ }
+
+ public bool Equals(ThermalByte other)
+ {
+ return _coreValue == other._coreValue;
+ }
+
+ public static bool operator ==(ThermalByte left, ThermalByte right)
+ {
+ return left.Equals(right);
+ }
+
+ public static bool operator !=(ThermalByte left, ThermalByte right)
+ {
+ return !left.Equals(right);
+ }
+
+ public override bool Equals(object? obj)
+ {
+ return obj is ThermalByte other && Equals(other);
+ }
+
+ public override int GetHashCode()
+ {
+ return _coreValue.GetHashCode();
+ }
}
diff --git a/Content.Shared/CCVar/CCVars.Net.cs b/Content.Shared/CCVar/CCVars.Net.cs
index b7465def2eb..8cd8857d96e 100644
--- a/Content.Shared/CCVar/CCVars.Net.cs
+++ b/Content.Shared/CCVar/CCVars.Net.cs
@@ -1,4 +1,4 @@
-using Robust.Shared.Configuration;
+using Robust.Shared.Configuration;
namespace Content.Shared.CCVar;
diff --git a/Content.Shared/Inventory/InventorySystem.Relay.cs b/Content.Shared/Inventory/InventorySystem.Relay.cs
index c2a39f60553..5957370b28d 100644
--- a/Content.Shared/Inventory/InventorySystem.Relay.cs
+++ b/Content.Shared/Inventory/InventorySystem.Relay.cs
@@ -109,6 +109,7 @@ public partial class InventorySystem
SubscribeLocalEvent>(RefRelayInventoryEvent);
SubscribeLocalEvent>(RefRelayInventoryEvent); // DeltaV - Medical Records
SubscribeLocalEvent>(RefRelayInventoryEvent);
+ SubscribeLocalEvent>(RefRelayInventoryEvent);
SubscribeLocalEvent>(OnGetEquipmentVerbs);
SubscribeLocalEvent>(OnGetInnateVerbs);
diff --git a/Content.Shared/Overlays/ThermalSightComponent.cs b/Content.Shared/Overlays/ThermalSightComponent.cs
new file mode 100644
index 00000000000..df199728dcb
--- /dev/null
+++ b/Content.Shared/Overlays/ThermalSightComponent.cs
@@ -0,0 +1,10 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Overlays;
+
+///
+/// Makes the entity see air temperature.
+/// When added to a clothing item it will also grant the wearer the same overlay.
+///
+[RegisterComponent, NetworkedComponent]
+public sealed partial class ThermalSightComponent : Component;
diff --git a/Resources/Prototypes/Entities/Clothing/Eyes/glasses.yml b/Resources/Prototypes/Entities/Clothing/Eyes/glasses.yml
index 101b32f6609..965e7a91228 100644
--- a/Resources/Prototypes/Entities/Clothing/Eyes/glasses.yml
+++ b/Resources/Prototypes/Entities/Clothing/Eyes/glasses.yml
@@ -238,6 +238,26 @@
- type: IdentityBlocker
coverage: EYES
+#Make a scanner category when these actually function and we get the trayson
+- type: entity
+ parent: ClothingEyesBase
+ id: ClothingEyesGlassesThermal
+ name: optical thermal scanner
+ description: Thermals in the shape of glasses.
+ components:
+ - type: Sprite
+ sprite: Clothing/Eyes/Glasses/thermal.rsi
+ - type: Clothing
+ sprite: Clothing/Eyes/Glasses/thermal.rsi
+ - type: Armor
+ modifiers:
+ coefficients:
+ Heat: 0.95
+ - type: GroupExamine
+ - type: IdentityBlocker
+ coverage: EYES
+ - type: ThermalSight
+
- type: entity
parent: ClothingEyesBase
id: ClothingEyesGlassesChemical