From eca952e846c986c4ac0ca678c5b57fb91988b0cb Mon Sep 17 00:00:00 2001 From: InsoPL Date: Fri, 6 Feb 2026 11:30:19 +0100 Subject: [PATCH] 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> --- .../GasTileDangerousTemperatureOverlay.cs | 253 ++++++++++++++ .../Overlays/ThermalSightOverlaySystem.cs | 34 ++ .../Atmos/SharedGasTileOverlaySystemTest.cs | 103 ++++++ .../EntitySystems/GasTileOverlaySystem.cs | 47 ++- .../SharedGasTileOverlaySystem.cs | 325 +++++++++++++----- Content.Shared/CCVar/CCVars.Net.cs | 2 +- .../Inventory/InventorySystem.Relay.cs | 1 + .../Overlays/ThermalSightComponent.cs | 10 + .../Entities/Clothing/Eyes/glasses.yml | 1 + 9 files changed, 672 insertions(+), 104 deletions(-) create mode 100644 Content.Client/Atmos/Overlays/GasTileDangerousTemperatureOverlay.cs create mode 100644 Content.Client/Overlays/ThermalSightOverlaySystem.cs create mode 100644 Content.IntegrationTests/Tests/Atmos/SharedGasTileOverlaySystemTest.cs create mode 100644 Content.Shared/Overlays/ThermalSightComponent.cs 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 280a99cd326..ce8a6b1f508 100644 --- a/Content.Shared/Inventory/InventorySystem.Relay.cs +++ b/Content.Shared/Inventory/InventorySystem.Relay.cs @@ -95,6 +95,7 @@ public partial class InventorySystem SubscribeLocalEvent>(RefRelayInventoryEvent); SubscribeLocalEvent>(RefRelayInventoryEvent); 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 aa0aa8a083b..ccb5777b2d8 100644 --- a/Resources/Prototypes/Entities/Clothing/Eyes/glasses.yml +++ b/Resources/Prototypes/Entities/Clothing/Eyes/glasses.yml @@ -249,6 +249,7 @@ - type: GroupExamine - type: IdentityBlocker coverage: EYES + - type: ThermalSight - type: entity parent: ClothingEyesBase