Optical thermal scanner implementation (#42613)

* revert of the revert

* tests

* changes

* more fun

* test

* ccvvvar

* works but bad

* now its better

* more fixes

* more cleanup

* cleaning

* last fixes before move to glasses activ

* x

* glasses only

* working

* fix toolbox

* cleanup

* ThermalByte added

* small fix

* small optimalisations

* float bux fix

* comments add

* more comments

* more comments

* last fix

* revert cvar delete

* wrong blue shades

* cvar refactor

* Update Content.Shared/Atmos/EntitySystems/SharedGasTileOverlaySystem.cs

Co-authored-by: ArtisticRoomba <145879011+ArtisticRoomba@users.noreply.github.com>

* Update Content.Client/Atmos/Overlays/GasTileDangerousTemperatureOverlay.cs

Co-authored-by: ArtisticRoomba <145879011+ArtisticRoomba@users.noreply.github.com>

* tweak to TryGetTemperature comment

* Factors are now const

* renames

* Interface for ThermalByte

* tile color  vaccum and more comments

* saving yeeted

* integration test

* rename and cleanup

* fix

* cleanup

* switch

* UT fix (hopefully)

* small bug+ rename

* vaccum limit  + space is now invalid

* typo

* typo

* fix

* cleanup

---------

Co-authored-by: ArtisticRoomba <145879011+ArtisticRoomba@users.noreply.github.com>
This commit is contained in:
InsoPL
2026-02-06 11:30:19 +01:00
committed by GitHub
parent 991a3e9c22
commit eca952e846
9 changed files with 672 additions and 104 deletions

View File

@@ -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;
/// <summary>
/// Renders a thermal heatmap overlay for gas tiles, used for equipment like thermal glasses.
/// /// </summary>
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<GasTileOverlayComponent> _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<SharedTransformSystem>();
_overlayQuery = _entManager.GetEntityQuery<GasTileOverlayComponent>();
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
}
/// <summary>
/// Used for Calculating onscreen color from ThermalByte core value
/// /// </summary>
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<GasTileOverlaySystem>();
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<Entity<MapGridComponent>> 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();
}
}

View File

@@ -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<ThermalSightComponent>
{
[Dependency] private readonly IOverlayManager _overlayMan = default!;
private GasTileDangerousTemperatureOverlay _temperatureOverlay = default!;
public override void Initialize()
{
base.Initialize();
_temperatureOverlay = new();
}
protected override void UpdateInternal(RefreshEquipmentHudEvent<ThermalSightComponent> component)
{
base.UpdateInternal(component);
_overlayMan.AddOverlay(_temperatureOverlay);
}
protected override void DeactivateInternal()
{
base.DeactivateInternal();
_overlayMan.RemoveOverlay(_temperatureOverlay);
}
}

View File

@@ -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;
/// <summary>
/// GasTileOverlay is being tested here
/// </summary>
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<SharedMapSystem>();
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<GasTileOverlayComponent>(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());
}
}

View File

@@ -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<RoundRestartCleanupEvent>(Reset);
SubscribeLocalEvent<GasTileOverlayComponent, ComponentStartup>(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);
}
}
}

View File

@@ -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!;
/// <summary>
/// array of the ids of all visible gases.
/// </summary>
public int[] VisibleGasId = default!;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<GasTileOverlayComponent, ComponentGetState>(OnGetState);
List<int> 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<Vector2i, GasOverlayChunk>();
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<GasOverlayData>
{
[ViewVariables] public readonly byte FireState;
[ViewVariables] public readonly byte[] Opacity;
// TODO change fire color based on ByteTemp
/// <summary>
/// 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.
/// </summary>
public int[] VisibleGasId = default!;
[ViewVariables]
public readonly ThermalByte ByteGasTemperature;
public override void Initialize()
public GasOverlayData(byte fireState, byte[] opacity, ThermalByte byteTemp)
{
base.Initialize();
SubscribeLocalEvent<GasTileOverlayComponent, ComponentGetState>(OnGetState);
List<int> 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<Vector2i, GasOverlayChunk>();
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<GasOverlayData>
{
[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<NetEntity, List<GasOverlayChunk>> UpdatedChunks = new();
public Dictionary<NetEntity, HashSet<Vector2i>> RemovedChunks = new();
if (ByteGasTemperature != other.ByteGasTemperature)
return false;
return true;
}
}
[Serializable, NetSerializable]
public sealed class GasOverlayUpdateEvent : EntityEventArgs
{
public Dictionary<NetEntity, List<GasOverlayChunk>> UpdatedChunks = new();
public Dictionary<NetEntity, HashSet<Vector2i>> RemovedChunks = new();
}
}
/// <summary>
/// Struct for networking gas temperatures to all clients using a single struct(byte) per tile.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// The remaining bytes are used as special flags:
/// <list type="bullet">
/// <item><description><b>255</b>: Represents a Wall (block cannot hold atmosphere).</description></item>
/// <item><description><b>254</b>: Represents a Vacuum.</description></item>
/// <item><description><b>251-253</b>: Reserved for future use.</description></item>
/// </list>
/// </para>
/// <para>
/// <b>Dirtying Logic:</b> 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).
/// </para>
/// <para>
/// 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).
/// </para>
/// </remarks>
[Serializable, NetSerializable]
public struct ThermalByte : IEquatable<ThermalByte>
{
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;
}
/// <summary>
/// Set temperature of air in this in Kelvin.
/// </summary>
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;
/// <summary>
/// Attempts to get the air temperature in Kelvin.
/// </summary>
/// <param name="temperature">The temperature in Kelvin, if the tile has a valid temperature.</param>
/// <param name="onVacuumReturnTcmb">
/// If true and the tile is a vacuum, <paramref name="temperature"/> will be set to <see cref="Atmospherics.TCMB"/>
/// and the method will return <see langword="true"/>.
/// </param>
/// <returns>
/// <see langword="true"/> if the tile contains a valid temperature (including vacuum if <paramref name="onVacuumReturnTcmb"/> is set);
/// otherwise <see langword="false"/> (e.g., walls).
/// </returns>
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();
}
}

View File

@@ -1,4 +1,4 @@
using Robust.Shared.Configuration;
using Robust.Shared.Configuration;
namespace Content.Shared.CCVar;

View File

@@ -95,6 +95,7 @@ public partial class InventorySystem
SubscribeLocalEvent<InventoryComponent, RefreshEquipmentHudEvent<ShowCriminalRecordIconsComponent>>(RefRelayInventoryEvent);
SubscribeLocalEvent<InventoryComponent, RefreshEquipmentHudEvent<BlackAndWhiteOverlayComponent>>(RefRelayInventoryEvent);
SubscribeLocalEvent<InventoryComponent, RefreshEquipmentHudEvent<NoirOverlayComponent>>(RefRelayInventoryEvent);
SubscribeLocalEvent<InventoryComponent, RefreshEquipmentHudEvent<ThermalSightComponent>>(RefRelayInventoryEvent);
SubscribeLocalEvent<InventoryComponent, GetVerbsEvent<EquipmentVerb>>(OnGetEquipmentVerbs);
SubscribeLocalEvent<InventoryComponent, GetVerbsEvent<InnateVerb>>(OnGetInnateVerbs);

View File

@@ -0,0 +1,10 @@
using Robust.Shared.GameStates;
namespace Content.Shared.Overlays;
/// <summary>
/// Makes the entity see air temperature.
/// When added to a clothing item it will also grant the wearer the same overlay.
/// </summary>
[RegisterComponent, NetworkedComponent]
public sealed partial class ThermalSightComponent : Component;

View File

@@ -249,6 +249,7 @@
- type: GroupExamine
- type: IdentityBlocker
coverage: EYES
- type: ThermalSight
- type: entity
parent: ClothingEyesBase