diff --git a/Content.Server/Atmos/EntitySystems/AtmosphereSystem.Hotspot.cs b/Content.Server/Atmos/EntitySystems/AtmosphereSystem.Hotspot.cs index 613fcd14a6..c2702679d9 100644 --- a/Content.Server/Atmos/EntitySystems/AtmosphereSystem.Hotspot.cs +++ b/Content.Server/Atmos/EntitySystems/AtmosphereSystem.Hotspot.cs @@ -10,198 +10,280 @@ using Robust.Shared.Map.Components; using Robust.Shared.Prototypes; using Robust.Shared.Random; -namespace Content.Server.Atmos.EntitySystems +namespace Content.Server.Atmos.EntitySystems; + +public sealed partial class AtmosphereSystem { - public sealed partial class AtmosphereSystem + /* + Handles Hotspots, which are gas-based tile fires that slowly grow and spread + to adjacent tiles if conditions are met. + + You can think of a hotspot as a small flame on a tile that + grows by consuming a fuel and oxidizer from the tile's air, + with a certain volume and temperature. + + This volume grows bigger and bigger as the fire continues, + until it effectively engulfs the entire tile, at which point + it starts spreading to adjacent tiles by radiating heat. + */ + + /// + /// Collection of hotspot sounds to play. + /// + private static readonly ProtoId DefaultHotspotSounds = "AtmosHotspot"; + + [Dependency] private readonly DecalSystem _decalSystem = default!; + [Dependency] private readonly IRobustRandom _random = default!; + + /// + /// Number of cycles the hotspot system must process before it can play another sound + /// on a hotspot. + /// + private const int HotspotSoundCooldownCycles = 200; + + /// + /// Cooldown counter for hotspot sounds. + /// + private int _hotspotSoundCooldown = 0; + + [ViewVariables(VVAccess.ReadWrite)] + public SoundSpecifier? HotspotSound = new SoundCollectionSpecifier(DefaultHotspotSounds); + + /// + /// Processes a hotspot on a . + /// + /// The grid entity that belongs to the tile to process. + /// The to process. + private void ProcessHotspot( + Entity ent, + TileAtmosphere tile) { - private static readonly ProtoId DefaultHotspotSounds = "AtmosHotspot"; + var gridAtmosphere = ent.Comp1; - [Dependency] private readonly DecalSystem _decalSystem = default!; - [Dependency] private readonly IRobustRandom _random = default!; - - private const int HotspotSoundCooldownCycles = 200; - - private int _hotspotSoundCooldown = 0; - - [ViewVariables(VVAccess.ReadWrite)] - public SoundSpecifier? HotspotSound { get; private set; } = new SoundCollectionSpecifier(DefaultHotspotSounds); - - private void ProcessHotspot( - Entity ent, - TileAtmosphere tile) + // Hotspots that have fizzled out are assigned a new Hotspot struct + // with Valid set to false, so we can just check that here in + // one central place instead of manually removing it everywhere. + if (!tile.Hotspot.Valid) { - var gridAtmosphere = ent.Comp1; - if (!tile.Hotspot.Valid) + gridAtmosphere.HotspotTiles.Remove(tile); + return; + } + + AddActiveTile(gridAtmosphere, tile); + + // Prevent the hotspot from processing on the same cycle it was created (???) + // TODO ATMOS: Is this even necessary anymore? The queue is kept per processing stage + // and is not updated until tne next cycle, so the condition of a hotspot being created + // and processed in the same cycle is impossible. + if (!tile.Hotspot.SkippedFirstProcess) + { + tile.Hotspot.SkippedFirstProcess = true; + return; + } + + if (tile.ExcitedGroup != null) + ExcitedGroupResetCooldowns(tile.ExcitedGroup); + + if (tile.Hotspot.Temperature < Atmospherics.FireMinimumTemperatureToExist || + tile.Hotspot.Volume <= 1f || + tile.Air == null || + tile.Air.GetMoles(Gas.Oxygen) < 0.5f || + tile.Air.GetMoles(Gas.Plasma) < 0.5f && tile.Air.GetMoles(Gas.Tritium) < 0.5f) + { + tile.Hotspot = new Hotspot(); + InvalidateVisuals(ent, tile); + return; + } + + PerformHotspotExposure(tile); + + // This tile has now turned into a full-blown tile-fire. + // Start applying fire effects and spreading to adjacent tiles. + if (tile.Hotspot.Bypassing) + { + tile.Hotspot.State = 3; + + var gridUid = ent.Owner; + var tilePos = tile.GridIndices; + + // Get the existing decals on the tile + var tileDecals = _decalSystem.GetDecalsInRange(gridUid, tilePos); + + // Count the burnt decals on the tile + var tileBurntDecals = 0; + + foreach (var set in tileDecals) { - gridAtmosphere.HotspotTiles.Remove(tile); - return; + if (Array.IndexOf(_burntDecals, set.Decal.Id) == -1) + continue; + + tileBurntDecals++; + + if (tileBurntDecals > 4) + break; } - AddActiveTile(gridAtmosphere, tile); - - if (!tile.Hotspot.SkippedFirstProcess) + // Add a random burned decal to the tile only if there are less than 4 of them + if (tileBurntDecals < 4) { - tile.Hotspot.SkippedFirstProcess = true; - return; + _decalSystem.TryAddDecal(_burntDecals[_random.Next(_burntDecals.Length)], + new EntityCoordinates(gridUid, tilePos), + out _, + cleanable: true); } - if(tile.ExcitedGroup != null) - ExcitedGroupResetCooldowns(tile.ExcitedGroup); - - if ((tile.Hotspot.Temperature < Atmospherics.FireMinimumTemperatureToExist) || (tile.Hotspot.Volume <= 1f) - || tile.Air == null || tile.Air.GetMoles(Gas.Oxygen) < 0.5f || (tile.Air.GetMoles(Gas.Plasma) < 0.5f && tile.Air.GetMoles(Gas.Tritium) < 0.5f)) + if (tile.Air.Temperature > Atmospherics.FireMinimumTemperatureToSpread) { - tile.Hotspot = new Hotspot(); - InvalidateVisuals(ent, tile); - return; - } - - PerformHotspotExposure(tile); - - if (tile.Hotspot.Bypassing) - { - tile.Hotspot.State = 3; - - var gridUid = ent.Owner; - var tilePos = tile.GridIndices; - - // Get the existing decals on the tile - var tileDecals = _decalSystem.GetDecalsInRange(gridUid, tilePos); - - // Count the burnt decals on the tile - var tileBurntDecals = 0; - - foreach (var set in tileDecals) + var radiatedTemperature = tile.Air.Temperature * Atmospherics.FireSpreadRadiosityScale; + foreach (var otherTile in tile.AdjacentTiles) { - if (Array.IndexOf(_burntDecals, set.Decal.Id) == -1) + // TODO ATMOS: This is sus. Suss this out. + // Spread this fire to other tiles by exposing them to a hotspot if air can flow there. + // Unsure as to why this is sus. + if (otherTile == null) continue; - tileBurntDecals++; - - if (tileBurntDecals > 4) - break; + if (!otherTile.Hotspot.Valid) + HotspotExpose(gridAtmosphere, otherTile, radiatedTemperature, Atmospherics.CellVolume / 4); } - - // Add a random burned decal to the tile only if there are less than 4 of them - if (tileBurntDecals < 4) - _decalSystem.TryAddDecal(_burntDecals[_random.Next(_burntDecals.Length)], new EntityCoordinates(gridUid, tilePos), out _, cleanable: true); - - if (tile.Air.Temperature > Atmospherics.FireMinimumTemperatureToSpread) - { - var radiatedTemperature = tile.Air.Temperature * Atmospherics.FireSpreadRadiosityScale; - foreach (var otherTile in tile.AdjacentTiles) - { - // TODO ATMOS: This is sus. Suss this out. - if (otherTile == null) - continue; - - if(!otherTile.Hotspot.Valid) - HotspotExpose(gridAtmosphere, otherTile, radiatedTemperature, Atmospherics.CellVolume/4); - } - } - } - else - { - tile.Hotspot.State = (byte) (tile.Hotspot.Volume > Atmospherics.CellVolume * 0.4f ? 2 : 1); - } - - if (tile.Hotspot.Temperature > tile.MaxFireTemperatureSustained) - tile.MaxFireTemperatureSustained = tile.Hotspot.Temperature; - - if (_hotspotSoundCooldown++ == 0 && HotspotSound != null) - { - var coordinates = _mapSystem.ToCenterCoordinates(tile.GridIndex, tile.GridIndices); - - // A few details on the audio parameters for fire. - // The greater the fire state, the lesser the pitch variation. - // The greater the fire state, the greater the volume. - _audio.PlayPvs(HotspotSound, coordinates, HotspotSound.Params.WithVariation(0.15f / tile.Hotspot.State).WithVolume(-5f + 5f * tile.Hotspot.State)); - } - - if (_hotspotSoundCooldown > HotspotSoundCooldownCycles) - _hotspotSoundCooldown = 0; - - // TODO ATMOS Maybe destroy location here? - } - - private void HotspotExpose(GridAtmosphereComponent gridAtmosphere, TileAtmosphere tile, - float exposedTemperature, float exposedVolume, bool soh = false, EntityUid? sparkSourceUid = null) - { - if (tile.Air == null) - return; - - var oxygen = tile.Air.GetMoles(Gas.Oxygen); - - if (oxygen < 0.5f) - return; - - var plasma = tile.Air.GetMoles(Gas.Plasma); - var tritium = tile.Air.GetMoles(Gas.Tritium); - - if (tile.Hotspot.Valid) - { - if (soh) - { - if (plasma > 0.5f || tritium > 0.5f) - { - if (tile.Hotspot.Temperature < exposedTemperature) - tile.Hotspot.Temperature = exposedTemperature; - if (tile.Hotspot.Volume < exposedVolume) - tile.Hotspot.Volume = exposedVolume; - } - } - - return; - } - - if ((exposedTemperature > Atmospherics.PlasmaMinimumBurnTemperature) && (plasma > 0.5f || tritium > 0.5f)) - { - if (sparkSourceUid.HasValue) - _adminLog.Add(LogType.Flammable, LogImpact.High, $"Heat/spark of {ToPrettyString(sparkSourceUid.Value)} caused atmos ignition of gas: {tile.Air.Temperature.ToString():temperature}K - {oxygen}mol Oxygen, {plasma}mol Plasma, {tritium}mol Tritium"); - - tile.Hotspot = new Hotspot - { - Volume = exposedVolume * 25f, - Temperature = exposedTemperature, - SkippedFirstProcess = tile.CurrentCycle > gridAtmosphere.UpdateCounter, - Valid = true, - State = 1 - }; - - AddActiveTile(gridAtmosphere, tile); - gridAtmosphere.HotspotTiles.Add(tile); } } - - private void PerformHotspotExposure(TileAtmosphere tile) + else { - if (tile.Air == null || !tile.Hotspot.Valid) return; + // Little baby fire. Set the sprite state based on the current size of the fire. + tile.Hotspot.State = (byte)(tile.Hotspot.Volume > Atmospherics.CellVolume * 0.4f ? 2 : 1); + } - tile.Hotspot.Bypassing = tile.Hotspot.SkippedFirstProcess && tile.Hotspot.Volume > tile.Air.Volume*0.95f; + if (tile.Hotspot.Temperature > tile.MaxFireTemperatureSustained) + tile.MaxFireTemperatureSustained = tile.Hotspot.Temperature; - if (tile.Hotspot.Bypassing) + if (_hotspotSoundCooldown++ == 0 && HotspotSound != null) + { + var coordinates = _mapSystem.ToCenterCoordinates(tile.GridIndex, tile.GridIndices); + + // A few details on the audio parameters for fire. + // The greater the fire state, the lesser the pitch variation. + // The greater the fire state, the greater the volume. + _audio.PlayPvs(HotspotSound, + coordinates, + HotspotSound.Params.WithVariation(0.15f / tile.Hotspot.State) + .WithVolume(-5f + 5f * tile.Hotspot.State)); + } + + if (_hotspotSoundCooldown > HotspotSoundCooldownCycles) + _hotspotSoundCooldown = 0; + + // TODO ATMOS Maybe destroy location here? + } + + /// + /// Exposes a tile to a hotspot of given temperature and volume, igniting it if conditions are met. + /// + /// The of the grid the tile is on. + /// The to expose. + /// The temperature of the hotspot to expose. + /// You can think of this as exposing a temperature of a flame. + /// The volume of the hotspot to expose. + /// You can think of this as how big the flame is initially. + /// Bigger flames will ramp a fire faster. + /// Whether to "boost" a fire that's currently on the tile already. + /// Does nothing if the tile isn't already a hotspot. + /// This clamps the temperature and volume of the hotspot to the maximum + /// of the provided parameters and whatever's on the tile. + /// Entity that started the exposure for admin logging. + private void HotspotExpose(GridAtmosphereComponent gridAtmosphere, + TileAtmosphere tile, + float exposedTemperature, + float exposedVolume, + bool soh = false, + EntityUid? sparkSourceUid = null) + { + if (tile.Air == null) + return; + + var oxygen = tile.Air.GetMoles(Gas.Oxygen); + + if (oxygen < 0.5f) + return; + + var plasma = tile.Air.GetMoles(Gas.Plasma); + var tritium = tile.Air.GetMoles(Gas.Tritium); + + if (tile.Hotspot.Valid) + { + if (soh) { - tile.Hotspot.Volume = tile.Air.ReactionResults[(byte)GasReaction.Fire] * Atmospherics.FireGrowthRate; - tile.Hotspot.Temperature = tile.Air.Temperature; - } - else - { - var affected = tile.Air.RemoveVolume(tile.Hotspot.Volume); - affected.Temperature = tile.Hotspot.Temperature; - React(affected, tile); - tile.Hotspot.Temperature = affected.Temperature; - tile.Hotspot.Volume = affected.ReactionResults[(byte)GasReaction.Fire] * Atmospherics.FireGrowthRate; - Merge(tile.Air, affected); + if (plasma > 0.5f || tritium > 0.5f) + { + tile.Hotspot.Temperature = MathF.Max(tile.Hotspot.Temperature, exposedTemperature); + tile.Hotspot.Volume = MathF.Max(tile.Hotspot.Volume, exposedVolume); + } } - var fireEvent = new TileFireEvent(tile.Hotspot.Temperature, tile.Hotspot.Volume); - _entSet.Clear(); - _lookup.GetLocalEntitiesIntersecting(tile.GridIndex, tile.GridIndices, _entSet, 0f); + return; + } - foreach (var entity in _entSet) + if (exposedTemperature > Atmospherics.PlasmaMinimumBurnTemperature && (plasma > 0.5f || tritium > 0.5f)) + { + if (sparkSourceUid.HasValue) { - RaiseLocalEvent(entity, ref fireEvent); + _adminLog.Add(LogType.Flammable, + LogImpact.High, + $"Heat/spark of {ToPrettyString(sparkSourceUid.Value)} caused atmos ignition of gas: {tile.Air.Temperature.ToString():temperature}K - {oxygen}mol Oxygen, {plasma}mol Plasma, {tritium}mol Tritium"); } + + tile.Hotspot = new Hotspot + { + Volume = exposedVolume * 25f, + Temperature = exposedTemperature, + SkippedFirstProcess = tile.CurrentCycle > gridAtmosphere.UpdateCounter, + Valid = true, + State = 1 + }; + + AddActiveTile(gridAtmosphere, tile); + gridAtmosphere.HotspotTiles.Add(tile); + } + } + + /// + /// Performs hotspot exposure processing on a . + /// + /// The to process. + private void PerformHotspotExposure(TileAtmosphere tile) + { + if (tile.Air == null || !tile.Hotspot.Valid) + return; + + // Determine if the tile has become a full-blown fire if the volume of the fire has effectively reached + // the volume of the tile's air. + tile.Hotspot.Bypassing = tile.Hotspot.SkippedFirstProcess && tile.Hotspot.Volume > tile.Air.Volume * 0.95f; + + // If the tile is effectively a full fire, use the tile's air for reactions, don't bother partitioning. + if (tile.Hotspot.Bypassing) + { + tile.Hotspot.Volume = tile.Air.ReactionResults[(byte)GasReaction.Fire] * Atmospherics.FireGrowthRate; + tile.Hotspot.Temperature = tile.Air.Temperature; + } + // Otherwise, pull out a fraction of the tile's air (the current hotspot volume) to perform reactions on. + else + { + var affected = tile.Air.RemoveVolume(tile.Hotspot.Volume); + affected.Temperature = tile.Hotspot.Temperature; + React(affected, tile); + tile.Hotspot.Temperature = affected.Temperature; + // Scale the fire based on the type of reaction that occured. + tile.Hotspot.Volume = affected.ReactionResults[(byte)GasReaction.Fire] * Atmospherics.FireGrowthRate; + Merge(tile.Air, affected); + } + + var fireEvent = new TileFireEvent(tile.Hotspot.Temperature, tile.Hotspot.Volume); + _entSet.Clear(); + _lookup.GetLocalEntitiesIntersecting(tile.GridIndex, tile.GridIndices, _entSet, 0f); + + foreach (var entity in _entSet) + { + RaiseLocalEvent(entity, ref fireEvent); } } } diff --git a/Content.Server/Atmos/Hotspot.cs b/Content.Server/Atmos/Hotspot.cs index 987acf73c2..2783362c31 100644 --- a/Content.Server/Atmos/Hotspot.cs +++ b/Content.Server/Atmos/Hotspot.cs @@ -1,26 +1,57 @@ -namespace Content.Server.Atmos +namespace Content.Server.Atmos; + +/// +/// Internal Atmospherics struct that stores data about a hotspot in a tile. +/// Hotspots are used to model (slow-spreading) fires and firestarters. +/// +public struct Hotspot { - public struct Hotspot - { - [ViewVariables] - public bool Valid; + /// + /// Whether this hotspot is currently representing fire and needs to be processed. + /// Set when the hotspot "becomes alight". This is never set to false + /// because Atmospherics will just assign + /// a new struct when the fire goes out. + /// + [ViewVariables] + public bool Valid; - [ViewVariables] - public bool SkippedFirstProcess; + /// + /// Whether this hotspot has skipped its first process cycle. + /// AtmosphereSystem.Hotspot skips processing a hotspot beyond + /// setting it to active (for LINDA processing) the first + /// time it is processed. + /// + [ViewVariables] + public bool SkippedFirstProcess; - [ViewVariables] - public bool Bypassing; + /// + /// Whether this hotspot is currently using the tile for reacting and fire processing + /// instead of a fraction of the tile's air. + /// + /// When a tile is considered a hotspot, Hotspot will pull a fraction of that tile's + /// air out of the tile and perform a reaction on that air, merging it back afterward. + /// Bypassing triggers when the hotspot volume nears the tile's volume, making the system + /// use the tile's GasMixture instead of pulling a fraction out. + /// + [ViewVariables] + public bool Bypassing; - [ViewVariables] - public float Temperature; + /// + /// Current temperature of the hotspot's volume, in Kelvin. + /// + [ViewVariables] + public float Temperature; - [ViewVariables] - public float Volume; + /// + /// Current volume of the hotspot, in liters. + /// You can think of this as the volume of the current fire in the tile. + /// + [ViewVariables] + public float Volume; - /// - /// State for the fire sprite. - /// - [ViewVariables] - public byte State; - } + /// + /// State for the fire sprite. + /// + [ViewVariables] + public byte State; }