diff --git a/Content.Client/GameObjects/Components/Chemistry/FoamVisualizer.cs b/Content.Client/GameObjects/Components/Chemistry/FoamVisualizer.cs new file mode 100644 index 0000000000..79fb64a230 --- /dev/null +++ b/Content.Client/GameObjects/Components/Chemistry/FoamVisualizer.cs @@ -0,0 +1,74 @@ +#nullable enable +using System; +using Content.Shared.GameObjects.Components.Chemistry; +using JetBrains.Annotations; +using Robust.Client.Animations; +using Robust.Client.GameObjects; +using Robust.Client.GameObjects.Components.Animations; +using Robust.Client.Interfaces.GameObjects.Components; +using Robust.Shared.Maths; +using Robust.Shared.Utility; +using YamlDotNet.RepresentationModel; + +namespace Content.Client.GameObjects.Components.Chemistry +{ + [UsedImplicitly] + public class FoamVisualizer : AppearanceVisualizer + { + private const string AnimationKey = "foamdissolve_animation"; + private Animation _foamDissolve = new(); + public override void LoadData(YamlMappingNode node) + { + base.LoadData(node); + + var delay = 0.6f; + var state = "foam-dissolve"; + + if (node.TryGetNode("animationTime", out var delayNode)) + { + delay = delayNode.AsFloat(); + } + + if (node.TryGetNode("animationState", out var stateNode)) + { + state = stateNode.AsString(); + } + + _foamDissolve = new Animation {Length = TimeSpan.FromSeconds(delay)}; + var flick = new AnimationTrackSpriteFlick(); + _foamDissolve.AnimationTracks.Add(flick); + flick.LayerKey = FoamVisualLayers.Base; + flick.KeyFrames.Add(new AnimationTrackSpriteFlick.KeyFrame(state, 0f)); + } + + public override void OnChangeData(AppearanceComponent component) + { + base.OnChangeData(component); + + if (component.TryGetData(FoamVisuals.State, out var state)) + { + if (state) + { + if (component.Owner.TryGetComponent(out AnimationPlayerComponent? animPlayer)) + { + if (!animPlayer.HasRunningAnimation(AnimationKey)) + animPlayer.Play(_foamDissolve, AnimationKey); + } + } + } + + if (component.TryGetData(FoamVisuals.Color, out var color)) + { + if (component.Owner.TryGetComponent(out ISpriteComponent? sprite)) + { + sprite.Color = color; + } + } + } + } + + public enum FoamVisualLayers : byte + { + Base + } +} diff --git a/Content.Client/GameObjects/Components/Chemistry/SmokeVisualizer.cs b/Content.Client/GameObjects/Components/Chemistry/SmokeVisualizer.cs new file mode 100644 index 0000000000..91f68b6f71 --- /dev/null +++ b/Content.Client/GameObjects/Components/Chemistry/SmokeVisualizer.cs @@ -0,0 +1,26 @@ +#nullable enable +using Content.Shared.GameObjects.Components.Chemistry; +using JetBrains.Annotations; +using Robust.Client.GameObjects; +using Robust.Client.Interfaces.GameObjects.Components; +using Robust.Shared.Maths; + +namespace Content.Client.GameObjects.Components.Chemistry +{ + [UsedImplicitly] + public class SmokeVisualizer : AppearanceVisualizer + { + public override void OnChangeData(AppearanceComponent component) + { + base.OnChangeData(component); + + if (component.TryGetData(SmokeVisuals.Color, out var color)) + { + if (component.Owner.TryGetComponent(out ISpriteComponent? sprite)) + { + sprite.Color = color; + } + } + } + } +} diff --git a/Content.Client/IgnoredComponents.cs b/Content.Client/IgnoredComponents.cs index 652a75edee..0ad242868c 100644 --- a/Content.Client/IgnoredComponents.cs +++ b/Content.Client/IgnoredComponents.cs @@ -236,6 +236,8 @@ namespace Content.Client "SliceableFood", "DamageOtherOnHit", "DamageOnLand", + "SmokeSolutionAreaEffect", + "FoamSolutionAreaEffect", "GasFilter", "Recyclable", "SecretStash", diff --git a/Content.Server/Chemistry/ReactionEffects/AreaReactionEffect.cs b/Content.Server/Chemistry/ReactionEffects/AreaReactionEffect.cs new file mode 100644 index 0000000000..2079ac32b5 --- /dev/null +++ b/Content.Server/Chemistry/ReactionEffects/AreaReactionEffect.cs @@ -0,0 +1,162 @@ +#nullable enable +using System; +using Content.Server.GameObjects.Components.Chemistry; +using Content.Server.Interfaces.Chemistry; +using Content.Server.Utility; +using Content.Shared.Audio; +using JetBrains.Annotations; +using Robust.Server.GameObjects.EntitySystems; +using Robust.Shared.GameObjects.Systems; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Interfaces.Map; +using Robust.Shared.IoC; +using Robust.Shared.Log; +using Robust.Shared.Serialization; + +namespace Content.Server.Chemistry.ReactionEffects +{ + /// + /// Basically smoke and foam reactions. + /// + [UsedImplicitly] + public abstract class AreaReactionEffect : IReactionEffect + { + [Dependency] private readonly IMapManager _mapManager = default!; + + /// + /// Used for calculating the spread range of the effect based on the intensity of the reaction. + /// + private float _rangeConstant; + private float _rangeMultiplier; + private int _maxRange; + + /// + /// If true the reagents get diluted or concentrated depending on the range of the effect + /// + private bool _diluteReagents; + + /// + /// At what range should the reagents volume stay the same. If the effect range is higher than this then the reagents + /// will get diluted. If the effect range is lower than this then the reagents will get concentrated. + /// + private int _reagentDilutionStart; + + /// + /// Used to calculate dilution. Increasing this makes the reagents get more diluted. This means that a lower range + /// will be needed to make the reagents volume get closer to zero. + /// + private float _reagentDilutionFactor; + + /// + /// Used to calculate concentration. Reagents get linearly more concentrated as the range goes from + /// _reagentDilutionStart to zero. When the range is zero the reagents volume gets multiplied by this. + /// + private float _reagentMaxConcentrationFactor; + + /// + /// How many seconds will the effect stay, counting after fully spreading. + /// + private float _duration; + + /// + /// How many seconds between each spread step. + /// + private float _spreadDelay; + + /// + /// How many seconds between each remove step. + /// + private float _removeDelay; + + /// + /// The entity prototype that will be spawned as the effect. It needs a component derived from SolutionAreaEffectComponent. + /// + private string? _prototypeId; + + /// + /// Sound that will get played when this reaction effect occurs. + /// + private string? _sound; + + protected AreaReactionEffect() + { + IoCManager.InjectDependencies(this); + } + + public void ExposeData(ObjectSerializer serializer) + { + serializer.DataField(ref _rangeConstant, "rangeConstant",0f); + serializer.DataField(ref _rangeMultiplier, "rangeMultiplier",1.1f); + serializer.DataField(ref _maxRange, "maxRange", 10); + serializer.DataField(ref _diluteReagents, "diluteReagents", false); + serializer.DataField(ref _reagentDilutionStart, "reagentDilutionStart", 4); + serializer.DataField(ref _reagentDilutionFactor, "reagentDilutionFactor", 1f); + serializer.DataField(ref _reagentMaxConcentrationFactor, "reagentMaxConcentrationFactor",2f); + serializer.DataField(ref _duration, "duration", 10f); + serializer.DataField(ref _spreadDelay, "spreadDelay", 0.5f); + serializer.DataField(ref _removeDelay, "removeDelay", 0.5f); + serializer.DataField(ref _sound, "sound", null); + serializer.DataField(ref _prototypeId, "prototypeId", null); + + if (_prototypeId == null) + Logger.Error("prototypeId wasn't provided to AreaReactionEffect, check yaml"); + } + + public void React(IEntity solutionEntity, double intensity) + { + if (!solutionEntity.TryGetComponent(out SolutionContainerComponent? contents)) + return; + + var solution = contents.SplitSolution(contents.MaxVolume); + // We take the square root so it becomes harder to reach higher amount values + var amount = (int) Math.Round(_rangeConstant + _rangeMultiplier*Math.Sqrt(intensity)); + amount = Math.Min(amount, _maxRange); + + if (_diluteReagents) + { + // The maximum value of solutionFraction is _reagentMaxConcentrationFactor, achieved when amount = 0 + // The infimum of solutionFraction is 0, which is approached when amount tends to infinity + // solutionFraction is equal to 1 only when amount equals _reagentDilutionStart + float solutionFraction; + if (amount >= _reagentDilutionStart) + { + // Weird formulas here but basically when amount increases, solutionFraction gets closer to 0 in a reciprocal manner + // _reagentDilutionFactor defines how fast solutionFraction gets closer to 0 + solutionFraction = 1 / (_reagentDilutionFactor*(amount - _reagentDilutionStart) + 1); + } + else + { + // Here when amount decreases, solutionFraction gets closer to _reagentMaxConcentrationFactor in a linear manner + solutionFraction = amount * (1 - _reagentMaxConcentrationFactor) / _reagentDilutionStart + + _reagentMaxConcentrationFactor; + } + solution.RemoveSolution(solution.TotalVolume*(1-solutionFraction)); + } + + if (!_mapManager.TryFindGridAt(solutionEntity.Transform.MapPosition, out var grid)) return; + + var coords = grid.MapToGrid(solutionEntity.Transform.MapPosition); + + var ent = solutionEntity.EntityManager.SpawnEntity(_prototypeId, coords.SnapToGrid()); + + var areaEffectComponent = GetAreaEffectComponent(ent); + + if (areaEffectComponent == null) + { + Logger.Error("Couldn't get AreaEffectComponent from " + _prototypeId); + ent.Delete(); + return; + } + + areaEffectComponent.TryAddSolution(solution); + areaEffectComponent.Start(amount, _duration, _spreadDelay, _removeDelay); + + if (!string.IsNullOrEmpty(_sound)) + { + EntitySystem.Get().PlayFromEntity(_sound, solutionEntity, AudioHelpers.WithVariation(0.125f)); + } + } + + protected abstract SolutionAreaEffectComponent? GetAreaEffectComponent(IEntity entity); + } +} diff --git a/Content.Server/Chemistry/ReactionEffects/FoamAreaReactionEffect.cs b/Content.Server/Chemistry/ReactionEffects/FoamAreaReactionEffect.cs new file mode 100644 index 0000000000..edc6a26285 --- /dev/null +++ b/Content.Server/Chemistry/ReactionEffects/FoamAreaReactionEffect.cs @@ -0,0 +1,16 @@ +#nullable enable +using Content.Server.GameObjects.Components.Chemistry; +using JetBrains.Annotations; +using Robust.Shared.Interfaces.GameObjects; + +namespace Content.Server.Chemistry.ReactionEffects +{ + [UsedImplicitly] + public class FoamAreaReactionEffect : AreaReactionEffect + { + protected override SolutionAreaEffectComponent? GetAreaEffectComponent(IEntity entity) + { + return entity.GetComponentOrNull(); + } + } +} diff --git a/Content.Server/Chemistry/ReactionEffects/SmokeAreaReactionEffect.cs b/Content.Server/Chemistry/ReactionEffects/SmokeAreaReactionEffect.cs new file mode 100644 index 0000000000..3a78557321 --- /dev/null +++ b/Content.Server/Chemistry/ReactionEffects/SmokeAreaReactionEffect.cs @@ -0,0 +1,16 @@ +#nullable enable +using Content.Server.GameObjects.Components.Chemistry; +using JetBrains.Annotations; +using Robust.Shared.Interfaces.GameObjects; + +namespace Content.Server.Chemistry.ReactionEffects +{ + [UsedImplicitly] + public class SmokeAreaReactionEffect : AreaReactionEffect + { + protected override SolutionAreaEffectComponent? GetAreaEffectComponent(IEntity entity) + { + return entity.GetComponentOrNull(); + } + } +} diff --git a/Content.Server/GameObjects/Components/Body/Respiratory/InternalsComponent.cs b/Content.Server/GameObjects/Components/Body/Respiratory/InternalsComponent.cs index 6c3380da4e..63594ffcee 100644 --- a/Content.Server/GameObjects/Components/Body/Respiratory/InternalsComponent.cs +++ b/Content.Server/GameObjects/Components/Body/Respiratory/InternalsComponent.cs @@ -59,5 +59,15 @@ namespace Content.Server.GameObjects.Components.Body.Respiratory return true; } + public bool AreInternalsWorking() + { + return BreathToolEntity != null && + GasTankEntity != null && + BreathToolEntity.TryGetComponent(out BreathToolComponent? breathTool) && + breathTool.IsFunctional && + GasTankEntity.TryGetComponent(out GasTankComponent? gasTank) && + gasTank.Air != null; + } + } } diff --git a/Content.Server/GameObjects/Components/Chemistry/FoamSolutionAreaEffectComponent.cs b/Content.Server/GameObjects/Components/Chemistry/FoamSolutionAreaEffectComponent.cs new file mode 100644 index 0000000000..6b9e6dd3b5 --- /dev/null +++ b/Content.Server/GameObjects/Components/Chemistry/FoamSolutionAreaEffectComponent.cs @@ -0,0 +1,90 @@ +#nullable enable +using Content.Server.GameObjects.Components.Body.Circulatory; +using Content.Server.GameObjects.Components.GUI; +using Content.Server.GameObjects.Components.Items.Storage; +using Content.Shared.Chemistry; +using Content.Shared.GameObjects.Components.Chemistry; +using Content.Shared.GameObjects.Components.Inventory; +using Robust.Server.GameObjects; +using Robust.Shared.GameObjects; +using Robust.Shared.GameObjects.Components.Timers; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Serialization; + +namespace Content.Server.GameObjects.Components.Chemistry +{ + [RegisterComponent] + [ComponentReference(typeof(SolutionAreaEffectComponent))] + public class FoamSolutionAreaEffectComponent : SolutionAreaEffectComponent + { + public override string Name => "FoamSolutionAreaEffect"; + + private string? _foamedMetalPrototype; + + public override void ExposeData(ObjectSerializer serializer) + { + base.ExposeData(serializer); + serializer.DataField(ref _foamedMetalPrototype, "foamedMetalPrototype", null); + } + + protected override void UpdateVisuals() + { + if (Owner.TryGetComponent(out AppearanceComponent? appearance) && + SolutionContainerComponent != null) + { + appearance.SetData(FoamVisuals.Color, SolutionContainerComponent.Color.WithAlpha(0.80f)); + } + } + + protected override void ReactWithEntity(IEntity entity, double solutionFraction) + { + if (SolutionContainerComponent == null) + return; + + if (!entity.TryGetComponent(out BloodstreamComponent? bloodstream)) + return; + + // TODO: Add a permeability property to clothing + // For now it just adds to protection for each clothing equipped + var protection = 0f; + if (entity.TryGetComponent(out InventoryComponent? inventory)) + { + foreach (var slot in inventory.Slots) + { + if (slot == EquipmentSlotDefines.Slots.BACKPACK || + slot == EquipmentSlotDefines.Slots.POCKET1 || + slot == EquipmentSlotDefines.Slots.POCKET2 || + slot == EquipmentSlotDefines.Slots.IDCARD) + continue; + + if (inventory.TryGetSlotItem(slot, out ItemComponent _)) + protection += 0.025f; + } + } + + var cloneSolution = SolutionContainerComponent.Solution.Clone(); + var transferAmount = ReagentUnit.Min(cloneSolution.TotalVolume * solutionFraction * (1 - protection), bloodstream.EmptyVolume); + var transferSolution = cloneSolution.SplitSolution(transferAmount); + + bloodstream.TryTransferSolution(transferSolution); + } + + protected override void OnKill() + { + if (Owner.Deleted) + return; + if (Owner.TryGetComponent(out AppearanceComponent? appearance)) + { + appearance.SetData(FoamVisuals.State, true); + } + Owner.SpawnTimer(600, () => + { + if (!string.IsNullOrEmpty(_foamedMetalPrototype)) + { + Owner.EntityManager.SpawnEntity(_foamedMetalPrototype, Owner.Transform.Coordinates); + } + Owner.Delete(); + }); + } + } +} diff --git a/Content.Server/GameObjects/Components/Chemistry/SmokeSolutionAreaEffectComponent.cs b/Content.Server/GameObjects/Components/Chemistry/SmokeSolutionAreaEffectComponent.cs new file mode 100644 index 0000000000..f916725ad6 --- /dev/null +++ b/Content.Server/GameObjects/Components/Chemistry/SmokeSolutionAreaEffectComponent.cs @@ -0,0 +1,63 @@ +#nullable enable +using System.Linq; +using Content.Server.GameObjects.Components.Body.Circulatory; +using Content.Server.GameObjects.Components.Body.Respiratory; +using Content.Shared.Chemistry; +using Content.Shared.GameObjects.Components.Chemistry; +using Content.Shared.Interfaces.GameObjects.Components; +using Robust.Server.GameObjects; +using Robust.Shared.GameObjects; +using Robust.Shared.Interfaces.GameObjects; + +namespace Content.Server.GameObjects.Components.Chemistry +{ + [RegisterComponent] + [ComponentReference(typeof(SolutionAreaEffectComponent))] + public class SmokeSolutionAreaEffectComponent : SolutionAreaEffectComponent + { + public override string Name => "SmokeSolutionAreaEffect"; + + protected override void UpdateVisuals() + { + if (Owner.TryGetComponent(out AppearanceComponent? appearance) && + SolutionContainerComponent != null) + { + appearance.SetData(SmokeVisuals.Color, SolutionContainerComponent.Color); + } + } + + protected override void ReactWithEntity(IEntity entity, double solutionFraction) + { + if (SolutionContainerComponent == null) + return; + + if (!entity.TryGetComponent(out BloodstreamComponent? bloodstream)) + return; + + if (entity.TryGetComponent(out InternalsComponent? internals) && + internals.AreInternalsWorking()) + return; + + var cloneSolution = SolutionContainerComponent.Solution.Clone(); + var transferAmount = ReagentUnit.Min(cloneSolution.TotalVolume * solutionFraction, bloodstream.EmptyVolume); + var transferSolution = cloneSolution.SplitSolution(transferAmount); + + foreach (var reagentQuantity in transferSolution.Contents.ToArray()) + { + if (reagentQuantity.Quantity == ReagentUnit.Zero) continue; + var reagent = PrototypeManager.Index(reagentQuantity.ReagentId); + transferSolution.RemoveReagent(reagentQuantity.ReagentId,reagent.ReactionEntity(entity, ReactionMethod.Ingestion, reagentQuantity.Quantity)); + } + + bloodstream.TryTransferSolution(transferSolution); + } + + + protected override void OnKill() + { + if (Owner.Deleted) + return; + Owner.Delete(); + } + } +} diff --git a/Content.Server/GameObjects/Components/Chemistry/SolutionAreaEffectComponent.cs b/Content.Server/GameObjects/Components/Chemistry/SolutionAreaEffectComponent.cs new file mode 100644 index 0000000000..682cfac522 --- /dev/null +++ b/Content.Server/GameObjects/Components/Chemistry/SolutionAreaEffectComponent.cs @@ -0,0 +1,188 @@ +#nullable enable +using System; +using System.Linq; +using Content.Server.GameObjects.Components.Atmos; +using Content.Server.Utility; +using Content.Shared.Chemistry; +using Content.Shared.Interfaces.GameObjects.Components; +using Robust.Shared.GameObjects; +using Robust.Shared.GameObjects.ComponentDependencies; +using Robust.Shared.GameObjects.Components.Transform; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Interfaces.Map; +using Robust.Shared.IoC; +using Robust.Shared.Log; +using Robust.Shared.Maths; +using Robust.Shared.Prototypes; + +namespace Content.Server.GameObjects.Components.Chemistry +{ + /// + /// Used to clone its owner repeatedly and group up them all so they behave like one unit, that way you can have + /// effects that cover an area. Inherited by and . + /// + public abstract class SolutionAreaEffectComponent : Component + { + [Dependency] protected readonly IMapManager MapManager = default!; + [Dependency] protected readonly IPrototypeManager PrototypeManager = default!; + + [ComponentDependency] protected readonly SnapGridComponent? SnapGridComponent = default!; + [ComponentDependency] protected readonly SolutionContainerComponent? SolutionContainerComponent = default!; + public int Amount { get; set; } + public SolutionAreaEffectInceptionComponent? Inception { get; set; } + + /// + /// Adds an to owner so the effect starts spreading and reacting. + /// + /// The range of the effect + /// + /// + /// + public void Start(int amount, float duration, float spreadDelay, float removeDelay) + { + if (Inception != null) + return; + + if (Owner.HasComponent()) + return; + + Amount = amount; + var inception = Owner.AddComponent(); + + inception.Add(this); + inception.Setup(amount, duration, spreadDelay, removeDelay); + } + + /// + /// Gets called by an AreaEffectInceptionComponent. "Clones" Owner into the four directions and copies the + /// solution into each of them. + /// + public void Spread() + { + if (Owner.Prototype == null) + { + Logger.Error("AreaEffectComponent needs its owner to be spawned by a prototype."); + return; + } + + if (SnapGridComponent == null) + { + Logger.Error("AreaEffectComponent attached to " + Owner.Prototype.ID + + " couldn't get SnapGridComponent from owner."); + return; + } + + void SpreadToDir(Direction dir) + { + foreach (var neighbor in SnapGridComponent.GetInDir(dir)) + { + if (neighbor.TryGetComponent(out SolutionAreaEffectComponent? comp) && comp.Inception == Inception) + return; + + if (neighbor.TryGetComponent(out AirtightComponent? airtight) && airtight.AirBlocked) + return; + } + + var newEffect = + Owner.EntityManager.SpawnEntity(Owner.Prototype.ID, SnapGridComponent.DirectionToGrid(dir)); + + if (!newEffect.TryGetComponent(out SolutionAreaEffectComponent? effectComponent)) + { + newEffect.Delete(); + return; + } + + if (SolutionContainerComponent != null) + { + effectComponent.TryAddSolution(SolutionContainerComponent.Solution.Clone()); + } + + effectComponent.Amount = Amount - 1; + Inception?.Add(effectComponent); + } + + SpreadToDir(Direction.North); + SpreadToDir(Direction.East); + SpreadToDir(Direction.South); + SpreadToDir(Direction.West); + + } + + /// + /// Gets called by an AreaEffectInceptionComponent. + /// Removes this component from its inception and calls OnKill(). The implementation of OnKill() should + /// eventually delete the entity. + /// + public void Kill() + { + Inception?.Remove(this); + OnKill(); + } + + protected abstract void OnKill(); + + /// + /// Gets called by an AreaEffectInceptionComponent. + /// Makes this effect's reagents react with the tile its on and with the entities it covers. Also calls + /// ReactWithEntity on the entities so inheritors can implement more specific behavior. + /// + /// How many times will this get called over this area effect's duration, averaged + /// with the other area effects from the inception. + public void React(float averageExposures) + { + if (SolutionContainerComponent == null) + return; + + var mapGrid = MapManager.GetGrid(Owner.Transform.GridID); + var tile = mapGrid.GetTileRef(Owner.Transform.Coordinates.ToVector2i(Owner.EntityManager, MapManager)); + + var solutionFraction = 1 / Math.Floor(averageExposures); + + foreach (var reagentQuantity in SolutionContainerComponent.ReagentList.ToArray()) + { + if (reagentQuantity.Quantity == ReagentUnit.Zero) continue; + var reagent = PrototypeManager.Index(reagentQuantity.ReagentId); + + // React with the tile the effect is on + reagent.ReactionTile(tile, reagentQuantity.Quantity * solutionFraction); + + // Touch every entity on the tile + foreach (var entity in tile.GetEntitiesInTileFast()) + { + reagent.ReactionEntity(entity, ReactionMethod.Touch, reagentQuantity.Quantity * solutionFraction); + } + } + + foreach (var entity in tile.GetEntitiesInTileFast()) + { + ReactWithEntity(entity, solutionFraction); + } + } + + protected abstract void ReactWithEntity(IEntity entity, double solutionFraction); + + public void TryAddSolution(Solution solution) + { + if (solution.TotalVolume == 0) + return; + + if (SolutionContainerComponent == null) + return; + + var addSolution = + solution.SplitSolution(ReagentUnit.Min(solution.TotalVolume, SolutionContainerComponent.EmptyVolume)); + + SolutionContainerComponent.TryAddSolution(addSolution); + + UpdateVisuals(); + } + + protected abstract void UpdateVisuals(); + + public override void OnRemove() + { + base.OnRemove(); + Inception?.Remove(this); + } + } +} diff --git a/Content.Server/GameObjects/Components/Chemistry/SolutionAreaEffectInceptionComponent.cs b/Content.Server/GameObjects/Components/Chemistry/SolutionAreaEffectInceptionComponent.cs new file mode 100644 index 0000000000..2eb1ccebdc --- /dev/null +++ b/Content.Server/GameObjects/Components/Chemistry/SolutionAreaEffectInceptionComponent.cs @@ -0,0 +1,142 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using Robust.Shared.GameObjects; +using Robust.Shared.ViewVariables; + +namespace Content.Server.GameObjects.Components.Chemistry +{ + /// + /// The "mastermind" of a SolutionAreaEffect group. It gets updated by the SolutionAreaEffectSystem and tells the + /// group when to spread, react and remove itself. This makes the group act like a single unit. + /// + /// It should only be manually added to an entity by the and not with a prototype. + [RegisterComponent] + public class SolutionAreaEffectInceptionComponent : Component + { + public override string Name => "AreaEffectInception"; + + private const float ReactionDelay = 0.5f; + + private readonly HashSet _group = new(); + + [ViewVariables] private float _lifeTimer; + [ViewVariables] private float _spreadTimer; + [ViewVariables] private float _reactionTimer; + + [ViewVariables] private int _amountCounterSpreading; + [ViewVariables] private int _amountCounterRemoving; + + /// + /// How much time to wait after fully spread before starting to remove itself. + /// + [ViewVariables] private float _duration; + + /// + /// Time between each spread step. Decreasing this makes spreading faster. + /// + [ViewVariables] private float _spreadDelay; + + /// + /// Time between each remove step. Decreasing this makes removing faster. + /// + [ViewVariables] private float _removeDelay; + + /// + /// How many times will the effect react. As some entities from the group last a different amount of time than + /// others, they will react a different amount of times, so we calculate the average to make the group behave + /// a bit more uniformly. + /// + [ViewVariables] private float _averageExposures; + + public void Setup(int amount, float duration, float spreadDelay, float removeDelay) + { + _amountCounterSpreading = amount; + _duration = duration; + _spreadDelay = spreadDelay; + _removeDelay = removeDelay; + + // So the first square reacts immediately after spawning + _reactionTimer = ReactionDelay; + /* + The group takes amount*spreadDelay seconds to fully spread, same with fully disappearing. + The outer squares will last duration seconds. + The first square will last duration + how many seconds the group takes to fully spread and fully disappear, so + it will last duration + amount*(spreadDelay+removeDelay). + Thus, the average lifetime of the smokes will be (outerSmokeLifetime + firstSmokeLifetime)/2 = duration + amount*(spreadDelay+removeDelay)/2 + */ + _averageExposures = (duration + amount * (spreadDelay+removeDelay) / 2)/ReactionDelay; + } + + public void InceptionUpdate(float frameTime) + { + _group.RemoveWhere(effect => effect.Deleted); + if (_group.Count == 0) + return; + + // Make every outer square from the group spread + if (_amountCounterSpreading > 0) + { + _spreadTimer += frameTime; + if (_spreadTimer > _spreadDelay) + { + _spreadTimer -= _spreadDelay; + + var outerEffects = new HashSet(_group.Where(effect => effect.Amount == _amountCounterSpreading)); + foreach (var effect in outerEffects) + { + effect.Spread(); + } + + _amountCounterSpreading -= 1; + } + } + // Start counting for _duration after fully spreading + else + { + _lifeTimer += frameTime; + } + + // Delete every outer square + if (_lifeTimer > _duration) + { + _spreadTimer += frameTime; + if (_spreadTimer > _removeDelay) + { + _spreadTimer -= _removeDelay; + + var outerEffects = new HashSet(_group.Where(effect => effect.Amount == _amountCounterRemoving)); + foreach (var effect in outerEffects) + { + effect.Kill(); + } + + _amountCounterRemoving += 1; + } + } + + // Make every square from the group react with the tile and entities + _reactionTimer += frameTime; + if (_reactionTimer > ReactionDelay) + { + _reactionTimer -= ReactionDelay; + foreach (var effect in _group) + { + effect.React(_averageExposures); + } + } + } + + public void Add(SolutionAreaEffectComponent effect) + { + _group.Add(effect); + effect.Inception = this; + } + + public void Remove(SolutionAreaEffectComponent effect) + { + _group.Remove(effect); + effect.Inception = null; + } + } +} diff --git a/Content.Server/GameObjects/EntitySystems/SolutionAreaEffectSystem.cs b/Content.Server/GameObjects/EntitySystems/SolutionAreaEffectSystem.cs new file mode 100644 index 0000000000..79062a6d2a --- /dev/null +++ b/Content.Server/GameObjects/EntitySystems/SolutionAreaEffectSystem.cs @@ -0,0 +1,19 @@ +#nullable enable +using Content.Server.GameObjects.Components.Chemistry; +using JetBrains.Annotations; +using Robust.Shared.GameObjects.Systems; + +namespace Content.Server.GameObjects.EntitySystems +{ + [UsedImplicitly] + public class SolutionAreaEffectSystem : EntitySystem + { + public override void Update(float frameTime) + { + foreach (var inception in ComponentManager.EntityQuery()) + { + inception.InceptionUpdate(frameTime); + } + } + } +} diff --git a/Content.Shared/GameObjects/Components/Chemistry/FoamVisuals.cs b/Content.Shared/GameObjects/Components/Chemistry/FoamVisuals.cs new file mode 100644 index 0000000000..065ae3da7f --- /dev/null +++ b/Content.Shared/GameObjects/Components/Chemistry/FoamVisuals.cs @@ -0,0 +1,13 @@ +#nullable enable +using System; +using Robust.Shared.Serialization; + +namespace Content.Shared.GameObjects.Components.Chemistry +{ + [Serializable, NetSerializable] + public enum FoamVisuals : byte + { + State, + Color + } +} diff --git a/Content.Shared/GameObjects/Components/Chemistry/SmokeVisuals.cs b/Content.Shared/GameObjects/Components/Chemistry/SmokeVisuals.cs new file mode 100644 index 0000000000..0ded6dd5c0 --- /dev/null +++ b/Content.Shared/GameObjects/Components/Chemistry/SmokeVisuals.cs @@ -0,0 +1,12 @@ +#nullable enable +using System; +using Robust.Shared.Serialization; + +namespace Content.Shared.GameObjects.Components.Chemistry +{ + [Serializable, NetSerializable] + public enum SmokeVisuals : byte + { + Color + } +} diff --git a/Content.Shared/GameObjects/DrawDepth.cs b/Content.Shared/GameObjects/DrawDepth.cs index 9f445d2e48..eea05d7d94 100644 --- a/Content.Shared/GameObjects/DrawDepth.cs +++ b/Content.Shared/GameObjects/DrawDepth.cs @@ -22,7 +22,8 @@ namespace Content.Shared.GameObjects Objects = DrawDepthTag.Default, Items = DrawDepthTag.Default + 1, Mobs = DrawDepthTag.Default + 2, - Ghosts = DrawDepthTag.Default + 3, - Overlays = DrawDepthTag.Default + 4, + Effects = DrawDepthTag.Default + 3, + Ghosts = DrawDepthTag.Default + 4, + Overlays = DrawDepthTag.Default + 5, } } diff --git a/Resources/Audio/Effects/license.txt b/Resources/Audio/Effects/license.txt index 67629091c3..2587ffff73 100644 --- a/Resources/Audio/Effects/license.txt +++ b/Resources/Audio/Effects/license.txt @@ -1,2 +1,4 @@ hit_kick.ogg is made by Taira Komori (https://taira-komori.jpn.org/freesounden.html) + +smoke.ogg taken from https://github.com/tgstation/tgstation/blob/a5d362ce84e4f0c61026236d5ec84d3c81553664/sound/effects/smoke.ogg \ No newline at end of file diff --git a/Resources/Audio/Effects/smoke.ogg b/Resources/Audio/Effects/smoke.ogg new file mode 100644 index 0000000000..1a1aa3733e Binary files /dev/null and b/Resources/Audio/Effects/smoke.ogg differ diff --git a/Resources/Prototypes/Entities/Constructible/Specific/Dispensers/chem_dispenser.yml b/Resources/Prototypes/Entities/Constructible/Specific/Dispensers/chem_dispenser.yml index f7de3d2bf4..92840e7a79 100644 --- a/Resources/Prototypes/Entities/Constructible/Specific/Dispensers/chem_dispenser.yml +++ b/Resources/Prototypes/Entities/Constructible/Specific/Dispensers/chem_dispenser.yml @@ -21,6 +21,7 @@ - chem.Water - chem.Ethanol - chem.Glucose + - chem.Sugar - chem.Hydrogen - chem.Oxygen - chem.Sulfur diff --git a/Resources/Prototypes/Entities/Effects/chemistry_effects.yml b/Resources/Prototypes/Entities/Effects/chemistry_effects.yml new file mode 100644 index 0000000000..00e78fce7f --- /dev/null +++ b/Resources/Prototypes/Entities/Effects/chemistry_effects.yml @@ -0,0 +1,149 @@ +- type: entity + id: Smoke + name: smoke + abstract: true + components: + - type: Sprite + drawdepth: Effects + sprite: Effects/chemsmoke.rsi + state: chemsmoke + - type: Appearance + visuals: + - type: SmokeVisualizer + - type: Occluder + sizeX: 32 + sizeY: 32 + - type: SnapGrid + offset: Center + - type: SmokeSolutionAreaEffect + - type: SolutionContainer + maxVol: 600 + +- type: entity + id: Foam + name: foam + abstract: true + components: + - type: Sprite + netsync: false + drawdepth: Effects + color: "#ffffffcc" #Add some transparency + sprite: Effects/foam.rsi + state: foam + layers: + - state: foam + map: ["enum.FoamVisualLayers.Base"] + - type: AnimationPlayer + - type: Appearance + visuals: + - type: FoamVisualizer + animationTime: 0.6 + animationState: foam-dissolve + - type: SnapGrid + offset: Center + - type: Physics + shapes: + - !type:PhysShapeAabb + bounds: "-0.4,-0.4,0.4,0.4" + layer: + - MobImpassable + - type: FoamSolutionAreaEffect + - type: SolutionContainer + maxVol: 600 + - type: Slippery + +- type: entity + id: IronMetalFoam + name: iron metal foam + abstract: true + parent: Foam + components: + - type: Sprite + state: mfoam + layers: + - state: mfoam + map: ["enum.FoamVisualLayers.Base"] + - type: Appearance + visuals: + - type: FoamVisualizer + animationTime: 0.6 + animationState: mfoam-dissolve + - type: FoamSolutionAreaEffect + foamedMetalPrototype: FoamedIronMetal + +- type: entity + id: AluminiumMetalFoam + name: aluminium metal foam + abstract: true + parent: Foam + components: + - type: Sprite + state: mfoam + layers: + - state: mfoam + map: ["enum.FoamVisualLayers.Base"] + - type: Appearance + visuals: + - type: FoamVisualizer + animationTime: 0.6 + animationState: mfoam-dissolve + - type: FoamSolutionAreaEffect + foamedMetalPrototype: FoamedAluminiumMetal + +- type: entity + id: BaseFoamedMetal + name: base foamed metal + description: Keeps the air in and the greytide out. + abstract: true + placement: + mode: SnapgridCenter + snap: + - Wall + components: + - type: RCDDeconstructWhitelist + - type: Clickable + - type: InteractionOutline + - type: Sprite + netsync: false + drawdepth: Walls + - type: Physics + shapes: + - !type:PhysShapeAabb + layer: + - Opaque + - Impassable + - MobImpassable + - VaultImpassable + - SmallImpassable + - type: Occluder + sizeX: 32 + sizeY: 32 + - type: SnapGrid + offset: Center + - type: Airtight + - type: Damageable + resistances: metallicResistances + - type: Destructible + thresholds: + 50: + behaviors: + - !type:DoActsBehavior + acts: [ "Destruction" ] + +- type: entity + id: FoamedIronMetal + name: foamed iron metal + parent: BaseFoamedMetal + components: + - type: Sprite + sprite: Effects/foam.rsi + state: ironfoam + +- type: entity + id: FoamedAluminiumMetal + name: foamed aluminium metal + parent: BaseFoamedMetal + components: + - type: Sprite + sprite: Effects/foam.rsi + state: metalfoam diff --git a/Resources/Prototypes/Reagents/chemicals.yml b/Resources/Prototypes/Reagents/chemicals.yml index d7af2363f1..2a67394e45 100644 --- a/Resources/Prototypes/Reagents/chemicals.yml +++ b/Resources/Prototypes/Reagents/chemicals.yml @@ -126,6 +126,15 @@ - !type:AdjustHealth amount: -8 +- type: reagent + id: chem.FluorosulfuricAcid + name: fluorosulfuric acid + desc: An extremely corrosive chemical substance. + physicalDecs: strong-smelling + color: "#5050ff" + boilingPoint: 165 + meltingPoint: -87 + - type: reagent id: chem.TableSalt name: table salt @@ -241,3 +250,13 @@ meltingPoint: -80.7 tileReactions: - !type:FlammableTileReaction {} + +- type: reagent + id: chem.Fluorosurfactant + name: fluorosurfactant + desc: A perfluoronated sulfonic acid that forms a foam when mixed with water. + physicalDesc: opaque + color: "#9e6b38" + boilingPoint: 190.0 # Perfluorooctanoic Acid. + meltingPoint: 45.0 + diff --git a/Resources/Prototypes/Recipes/Reactions/chemicals.yml b/Resources/Prototypes/Recipes/Reactions/chemicals.yml index 05045b484f..f6f16984ad 100644 --- a/Resources/Prototypes/Recipes/Reactions/chemicals.yml +++ b/Resources/Prototypes/Recipes/Reactions/chemicals.yml @@ -40,6 +40,20 @@ products: chem.PolytrinicAcid: 3 +- type: reaction + id: react.FluorosulfuricAcid + reactants: + chem.Fluorine: + amount: 1 + chem.Hydrogen: + amount: 1 + chem.Potassium: + amount: 1 + chem.SulfuricAcid: + amount: 1 + products: + chem.FluorosulfuricAcid: 4 + - type: reaction id: react.PotassiumExplosion reactants: @@ -57,6 +71,94 @@ scaled: true #Scaled proportionally to amount of potassium and water maxScale: 30 #Explosion strength stops scaling at 30 potassium + 30 water +- type: reaction + id: react.Smoke + reactants: + chem.Phosphorus: + amount: 1 + chem.Potassium: + amount: 1 + chem.Sugar: + amount: 1 + effects: + - !type:SmokeAreaReactionEffect + rangeConstant: 0 + rangeMultiplier: 1.1 #Range formula: rangeConstant + rangeMultiplier*sqrt(ReactionUnits) + maxRange: 10 + duration: 10 + spreadDelay: 0.5 + removeDelay: 0.5 + diluteReagents: false + prototypeId: Smoke + sound: /Audio/Effects/smoke.ogg + +- type: reaction + id: react.Foam + reactants: + chem.Fluorosurfactant: + amount: 1 + chem.Water: + amount: 1 + effects: + - !type:FoamAreaReactionEffect + rangeConstant: 0 + rangeMultiplier: 1.1 #Range formula: rangeConstant + rangeMultiplier*sqrt(ReactionUnits) + maxRange: 10 + duration: 10 + spreadDelay: 1 + removeDelay: 0 + diluteReagents: true + reagentDilutionStart: 4 #At what range should the reagents start diluting + reagentDilutionFactor: 1 + reagentMaxConcentrationFactor: 2 #The reagents will get multiplied by this number if the range turns out to be 0 + prototypeId: Foam + +- type: reaction + id: react.IronMetalFoam + reactants: + chem.Iron: + amount: 3 + chem.FoamingAgent: + amount: 1 + chem.FluorosulfuricAcid: + amount: 1 + effects: + - !type:FoamAreaReactionEffect + rangeConstant: 0 + rangeMultiplier: 1.1 + maxRange: 10 + duration: 10 + spreadDelay: 1 + removeDelay: 0 + diluteReagents: true + reagentDilutionStart: 4 + reagentDilutionFactor: 1 + reagentMaxConcentrationFactor: 2 + prototypeId: IronMetalFoam + +- type: reaction + id: react.AluminiumMetalFoam + reactants: + chem.Aluminium: + amount: 3 + chem.FoamingAgent: + amount: 1 + chem.FluorosulfuricAcid: + amount: 1 + effects: + - !type:FoamAreaReactionEffect + rangeConstant: 0 + rangeMultiplier: 1.1 + maxRange: 10 + duration: 10 + spreadDelay: 1 + removeDelay: 0 + diluteReagents: true + reagentDilutionStart: 4 + reagentDilutionFactor: 1 + reagentMaxConcentrationFactor: 2 + prototypeId: AluminiumMetalFoam + - type: reaction id: react.TableSalt reactants: @@ -100,3 +202,15 @@ amount: 1 products: chem.Water: 2 + +- type: reaction + id: react.Fluorosurfactant + reactants: + chem.Carbon: + amount: 2 + chem.Fluorine: + amount: 2 + chem.SulfuricAcid: + amount: 1 + products: + chem.Fluorosurfactant: 5 diff --git a/Resources/Textures/Effects/chemsmoke.rsi/chemsmoke.png b/Resources/Textures/Effects/chemsmoke.rsi/chemsmoke.png new file mode 100644 index 0000000000..e85d446334 Binary files /dev/null and b/Resources/Textures/Effects/chemsmoke.rsi/chemsmoke.png differ diff --git a/Resources/Textures/Effects/chemsmoke.rsi/meta.json b/Resources/Textures/Effects/chemsmoke.rsi/meta.json new file mode 100644 index 0000000000..62514d1c2a --- /dev/null +++ b/Resources/Textures/Effects/chemsmoke.rsi/meta.json @@ -0,0 +1 @@ +{"version": 1, "size": {"x": 96, "y": 96}, "license": "CC-BY-SA-3.0", "copyright": "Taken from https://github.com/discordia-space/CEV-Eris/blob/81b3a082ccdfb425f36bbed6e5bc1f0faed346ec/icons/effects/chemsmoke.dmi", "states": [{"name": "chemsmoke", "directions": 4, "delays": [[0.2, 0.2, 0.2], [0.2, 0.2, 0.2], [0.2, 0.2, 0.2], [0.2, 0.2, 0.2]]}]} \ No newline at end of file diff --git a/Resources/Textures/Effects/foam.rsi/foam-dissolve.png b/Resources/Textures/Effects/foam.rsi/foam-dissolve.png new file mode 100644 index 0000000000..a99f7e3b8f Binary files /dev/null and b/Resources/Textures/Effects/foam.rsi/foam-dissolve.png differ diff --git a/Resources/Textures/Effects/foam.rsi/foam.png b/Resources/Textures/Effects/foam.rsi/foam.png new file mode 100644 index 0000000000..5f34c726ae Binary files /dev/null and b/Resources/Textures/Effects/foam.rsi/foam.png differ diff --git a/Resources/Textures/Effects/foam.rsi/ironfoam.png b/Resources/Textures/Effects/foam.rsi/ironfoam.png new file mode 100644 index 0000000000..bf39c4ee80 Binary files /dev/null and b/Resources/Textures/Effects/foam.rsi/ironfoam.png differ diff --git a/Resources/Textures/Effects/foam.rsi/meta.json b/Resources/Textures/Effects/foam.rsi/meta.json new file mode 100644 index 0000000000..33d13b267b --- /dev/null +++ b/Resources/Textures/Effects/foam.rsi/meta.json @@ -0,0 +1 @@ +{"version": 1, "size": {"x": 32, "y": 32}, "license": "CC-BY-SA-3.0", "copyright": "Taken from https://github.com/discordia-space/CEV-Eris/blob/81b3a082ccdfb425f36bbed6e5bc1f0faed346ec/icons/effects/effects.dmi", "states": [{"name": "foam", "directions": 1}, {"name": "foam-dissolve", "directions": 1, "delays": [[0.1, 0.1, 0.1, 0.1, 0.1, 0.1]]}, {"name": "ironfoam", "directions": 1}, {"name": "metalfoam", "directions": 1}, {"name": "mfoam", "directions": 1}, {"name": "mfoam-dissolve", "directions": 1, "delays": [[0.1, 0.1, 0.1, 0.1, 0.1, 0.1]]}]} \ No newline at end of file diff --git a/Resources/Textures/Effects/foam.rsi/metalfoam.png b/Resources/Textures/Effects/foam.rsi/metalfoam.png new file mode 100644 index 0000000000..cb4752a1bc Binary files /dev/null and b/Resources/Textures/Effects/foam.rsi/metalfoam.png differ diff --git a/Resources/Textures/Effects/foam.rsi/mfoam-dissolve.png b/Resources/Textures/Effects/foam.rsi/mfoam-dissolve.png new file mode 100644 index 0000000000..cd6d4a4cc1 Binary files /dev/null and b/Resources/Textures/Effects/foam.rsi/mfoam-dissolve.png differ diff --git a/Resources/Textures/Effects/foam.rsi/mfoam.png b/Resources/Textures/Effects/foam.rsi/mfoam.png new file mode 100644 index 0000000000..13ad9710e3 Binary files /dev/null and b/Resources/Textures/Effects/foam.rsi/mfoam.png differ