Files
wwdpublic/Content.Server/Atmos/EntitySystems/FlammableSystem.cs
VMSolidus e4cf2c5fb7 Flammable Performance Improvements (#2462)
# Description


![image](https://github.com/user-attachments/assets/8d33ca00-9ad7-4c51-8cbd-5ea09a3067a8)

I'm yeeting the server costs for the flammable system. This system will
no longer querry every entity that might be on fire to check if they're
on fire, and is instead querrying only entities that have a new
OnFireComponent, which is used to tell them that they're on fire. 99%
cost reductions are fun.

I have verified in testing that this works. 

# Changelog

🆑
- fix: Dramatically improved performance of the flammable system.
2025-07-12 12:20:34 +10:00

480 lines
19 KiB
C#

using Content.Server.Administration.Logs;
using Content.Server.Atmos.Components;
using Content.Server.IgnitionSource;
using Content.Server.Stunnable;
using Content.Server.Temperature.Components;
using Content.Server.Temperature.Systems;
using Content.Server.Damage.Components;
using Content.Shared.ActionBlocker;
using Content.Shared.Alert;
using Content.Shared.Atmos;
using Content.Shared.Atmos.Components;
using Content.Shared.Damage;
using Content.Shared.Database;
using Content.Shared.Interaction;
using Content.Shared.Inventory;
using Content.Shared.Physics;
using Content.Shared.Popups;
using Content.Shared.Projectiles;
using Content.Shared.Rejuvenate;
using Content.Shared.Temperature;
using Content.Shared.Throwing;
using Content.Shared.Timing;
using Content.Shared.Toggleable;
using Content.Shared.Weapons.Melee.Events;
using Content.Shared.FixedPoint;
using Robust.Server.Audio;
using Content.Shared.Mood;
using Robust.Shared.Physics.Components;
using Robust.Shared.Physics.Events;
using Robust.Shared.Physics.Systems;
using Robust.Shared.Random;
namespace Content.Server.Atmos.EntitySystems
{
public sealed class FlammableSystem : EntitySystem
{
[Dependency] private readonly ActionBlockerSystem _actionBlockerSystem = default!;
[Dependency] private readonly AtmosphereSystem _atmosphereSystem = default!;
[Dependency] private readonly StunSystem _stunSystem = default!;
[Dependency] private readonly TemperatureSystem _temperatureSystem = default!;
[Dependency] private readonly IgnitionSourceSystem _ignitionSourceSystem = default!;
[Dependency] private readonly DamageableSystem _damageableSystem = default!;
[Dependency] private readonly AlertsSystem _alertsSystem = default!;
[Dependency] private readonly FixtureSystem _fixture = default!;
[Dependency] private readonly IAdminLogManager _adminLogger = default!;
[Dependency] private readonly InventorySystem _inventory = default!;
[Dependency] private readonly SharedAppearanceSystem _appearance = default!;
[Dependency] private readonly SharedPopupSystem _popup = default!;
[Dependency] private readonly UseDelaySystem _useDelay = default!;
[Dependency] private readonly AudioSystem _audio = default!;
[Dependency] private readonly IRobustRandom _random = default!;
private EntityQuery<InventoryComponent> _inventoryQuery;
private EntityQuery<PhysicsComponent> _physicsQuery;
// This should probably be moved to the component, requires a rewrite, all fires tick at the same time
private const float UpdateTime = 1f;
private float _timer;
private readonly Dictionary<Entity<FlammableComponent>, float> _fireEvents = new();
public override void Initialize()
{
UpdatesAfter.Add(typeof(AtmosphereSystem));
_inventoryQuery = GetEntityQuery<InventoryComponent>();
_physicsQuery = GetEntityQuery<PhysicsComponent>();
SubscribeLocalEvent<FlammableComponent, MapInitEvent>(OnMapInit);
SubscribeLocalEvent<FlammableComponent, InteractUsingEvent>(OnInteractUsing);
SubscribeLocalEvent<FlammableComponent, StartCollideEvent>(OnCollide);
SubscribeLocalEvent<FlammableComponent, IsHotEvent>(OnIsHot);
SubscribeLocalEvent<FlammableComponent, TileFireEvent>(OnTileFire);
SubscribeLocalEvent<FlammableComponent, RejuvenateEvent>(OnRejuvenate);
SubscribeLocalEvent<IgniteOnCollideComponent, StartCollideEvent>(IgniteOnCollide);
SubscribeLocalEvent<IgniteOnCollideComponent, LandEvent>(OnIgniteLand);
SubscribeLocalEvent<IgniteOnMeleeHitComponent, MeleeHitEvent>(OnMeleeHit);
SubscribeLocalEvent<ExtinguishOnInteractComponent, ActivateInWorldEvent>(OnExtinguishActivateInWorld);
SubscribeLocalEvent<IgniteOnHeatDamageComponent, DamageChangedEvent>(OnDamageChanged);
}
private void OnMeleeHit(EntityUid uid, IgniteOnMeleeHitComponent component, MeleeHitEvent args)
{
foreach (var entity in args.HitEntities)
{
if (!TryComp<FlammableComponent>(entity, out var flammable))
continue;
AdjustFireStacks(entity, component.FireStacks, flammable);
if (component.FireStacks >= 0)
Ignite(entity, args.Weapon, flammable, args.User);
}
}
private void OnIgniteLand(EntityUid uid, IgniteOnCollideComponent component, ref LandEvent args)
{
RemCompDeferred<IgniteOnCollideComponent>(uid);
}
private void IgniteOnCollide(EntityUid uid, IgniteOnCollideComponent component, ref StartCollideEvent args)
{
if (!args.OtherFixture.Hard || component.Count == 0)
return;
var otherEnt = args.OtherEntity;
if (!EntityManager.TryGetComponent(otherEnt, out FlammableComponent? flammable))
return;
//Only ignite when the colliding fixture is projectile or ignition.
if (args.OurFixtureId != component.FixtureId && args.OurFixtureId != SharedProjectileSystem.ProjectileFixture)
{
return;
}
flammable.FireStacks += component.FireStacks;
Ignite(otherEnt, uid, flammable);
component.Count--;
if (component.Count == 0)
RemCompDeferred<IgniteOnCollideComponent>(uid);
}
private void OnMapInit(EntityUid uid, FlammableComponent component, MapInitEvent args)
{
// Sets up a fixture for flammable collisions.
// TODO: Should this be generalized into a general non-hard 'effects' fixture or something? I can't think of other use cases for it.
// This doesn't seem great either (lots more collisions generated) but there isn't a better way to solve it either that I can think of.
if (!TryComp<PhysicsComponent>(uid, out var body))
return;
_fixture.TryCreateFixture(uid, component.FlammableCollisionShape, component.FlammableFixtureID, hard: false,
collisionMask: (int) CollisionGroup.FullTileLayer, body: body);
}
private void OnInteractUsing(EntityUid uid, FlammableComponent flammable, InteractUsingEvent args)
{
if (args.Handled)
return;
var isHotEvent = new IsHotEvent();
RaiseLocalEvent(args.Used, isHotEvent);
if (!isHotEvent.IsHot)
return;
Ignite(uid, args.Used, flammable, args.User);
args.Handled = true;
}
private void OnExtinguishActivateInWorld(EntityUid uid, ExtinguishOnInteractComponent component, ActivateInWorldEvent args)
{
if (args.Handled || !args.Complex)
return;
if (!TryComp(uid, out FlammableComponent? flammable))
return;
if (!flammable.OnFire)
return;
args.Handled = true;
if (!TryComp(uid, out UseDelayComponent? useDelay) || !_useDelay.TryResetDelay((uid, useDelay), true))
return;
_audio.PlayPvs(component.ExtinguishAttemptSound, uid);
if (_random.Prob(component.Probability))
{
AdjustFireStacks(uid, component.StackDelta, flammable);
}
else
{
_popup.PopupEntity(Loc.GetString(component.ExtinguishFailed), uid);
}
}
private void OnCollide(EntityUid uid, FlammableComponent flammable, ref StartCollideEvent args)
{
var otherUid = args.OtherEntity;
// Collisions cause events to get raised directed at both entities. We only want to handle this collision
// once, hence the uid check.
if (otherUid.Id < uid.Id)
return;
// Normal hard collisions, though this isn't generally possible since most flammable things are mobs
// which don't collide with one another, shouldn't work here.
if (args.OtherFixtureId != flammable.FlammableFixtureID && args.OurFixtureId != flammable.FlammableFixtureID)
return;
if (!flammable.FireSpread)
return;
if (!TryComp(otherUid, out FlammableComponent? otherFlammable) || !otherFlammable.FireSpread)
return;
if (!flammable.OnFire && !otherFlammable.OnFire)
return; // Neither are on fire
// Both are on fire -> equalize fire stacks.
// Weight each thing's firestacks by its mass
var mass1 = 1f;
var mass2 = 1f;
if (_physicsQuery.TryComp(uid, out var physics) && _physicsQuery.TryComp(otherUid, out var otherPhys))
{
mass1 = physics.Mass;
mass2 = otherPhys.Mass;
}
// when the thing on fire is more massive than the other, the following happens:
// - the thing on fire loses a small number of firestacks
// - the other thing gains a large number of firestacks
// so a person on fire engulfs a mouse, but an engulfed mouse barely does anything to a person
var total = mass1 + mass2;
var avg = (flammable.FireStacks + otherFlammable.FireStacks) / total;
// swap the entity losing stacks depending on whichever has the most firestack kilos
var (src, dest) = flammable.FireStacks * mass1 > otherFlammable.FireStacks * mass2
? (-1f, 1f)
: (1f, -1f);
// bring each entity to the same firestack mass, firestacks being scaled by the other's mass
AdjustFireStacks(uid, src * avg * mass2, flammable, ignite: true);
AdjustFireStacks(otherUid, dest * avg * mass1, otherFlammable, ignite: true);
}
private void OnIsHot(EntityUid uid, FlammableComponent flammable, IsHotEvent args)
{
args.IsHot = flammable.OnFire;
}
private void OnTileFire(Entity<FlammableComponent> ent, ref TileFireEvent args)
{
var tempDelta = args.Temperature - ent.Comp.MinIgnitionTemperature;
_fireEvents.TryGetValue(ent, out var maxTemp);
if (tempDelta > maxTemp)
_fireEvents[ent] = tempDelta;
}
private void OnRejuvenate(EntityUid uid, FlammableComponent component, RejuvenateEvent args)
{
Extinguish(uid, component);
}
public void UpdateAppearance(EntityUid uid, FlammableComponent? flammable = null, AppearanceComponent? appearance = null)
{
if (!Resolve(uid, ref flammable, ref appearance))
return;
_appearance.SetData(uid, FireVisuals.OnFire, flammable.OnFire, appearance);
_appearance.SetData(uid, FireVisuals.FireStacks, flammable.FireStacks, appearance);
// Also enable toggleable-light visuals
// This is intended so that matches & candles can re-use code for un-shaded layers on in-hand sprites.
// However, this could cause conflicts if something is ACTUALLY both a toggleable light and flammable.
// if that ever happens, then fire visuals will need to implement their own in-hand sprite management.
_appearance.SetData(uid, ToggleableLightVisuals.Enabled, flammable.OnFire, appearance);
}
public void AdjustFireStacks(EntityUid uid, float relativeFireStacks, FlammableComponent? flammable = null, bool ignite = true)
{
if (!Resolve(uid, ref flammable))
return;
SetFireStacks(uid, flammable.FireStacks + relativeFireStacks, flammable, ignite);
}
public void SetFireStacks(EntityUid uid, float stacks, FlammableComponent? flammable = null, bool ignite = true)
{
if (!Resolve(uid, ref flammable))
return;
flammable.FireStacks = MathF.Min(MathF.Max(flammable.MinimumFireStacks, stacks), flammable.MaximumFireStacks);
if (flammable.FireStacks <= 0)
Extinguish(uid, flammable);
else
{
flammable.OnFire = flammable.OnFire ? flammable.OnFire : ignite; // WD EDIT
UpdateAppearance(uid, flammable);
}
}
public void Extinguish(EntityUid uid, FlammableComponent? flammable = null)
{
if (!Resolve(uid, ref flammable) || !flammable.CanExtinguish)
return;
RemCompDeferred<OnFireComponent>(uid);
if (!flammable.OnFire)
return;
_adminLogger.Add(LogType.Flammable, $"{ToPrettyString(uid):entity} stopped being on fire damage");
flammable.OnFire = false;
flammable.FireStacks = 0;
flammable.IgnoreFireProtection = false;
_ignitionSourceSystem.SetIgnited(uid, false);
UpdateAppearance(uid, flammable);
}
public void Ignite(EntityUid uid, EntityUid ignitionSource, FlammableComponent? flammable = null,
EntityUid? ignitionSourceUser = null, bool ignoreFireProtection = false)
{
if (!Resolve(uid, ref flammable, false)) // Lavaland Change: SHUT THE FUCK UP FLAMMABLE
return;
EnsureComp<OnFireComponent>(uid);
if (flammable.AlwaysCombustible)
{
flammable.FireStacks = Math.Max(flammable.FirestacksOnIgnite, flammable.FireStacks);
}
if (flammable.FireStacks > 0 && !flammable.OnFire)
{
if (ignitionSourceUser != null)
_adminLogger.Add(LogType.Flammable, $"{ToPrettyString(uid):target} set on fire by {ToPrettyString(ignitionSourceUser.Value):actor} with {ToPrettyString(ignitionSource):tool}");
else
_adminLogger.Add(LogType.Flammable, $"{ToPrettyString(uid):target} set on fire by {ToPrettyString(ignitionSource):actor}");
flammable.OnFire = true;
}
if (ignoreFireProtection)
flammable.IgnoreFireProtection = ignoreFireProtection;
UpdateAppearance(uid, flammable);
}
private void OnDamageChanged(EntityUid uid, IgniteOnHeatDamageComponent component, DamageChangedEvent args)
{
// Make sure the entity is flammable
if (!TryComp<FlammableComponent>(uid, out var flammable))
return;
// Make sure the damage delta isn't null
if (args.DamageDelta == null)
return;
// Check if its' taken any heat damage, and give the value
if (args.DamageDelta.DamageDict.TryGetValue("Heat", out FixedPoint2 value))
{
// Make sure the value is greater than the threshold
if(value <= component.Threshold)
return;
// Ignite that sucker
flammable.FireStacks += component.FireStacks;
Ignite(uid, uid, flammable);
}
}
public void Resist(EntityUid uid,
FlammableComponent? flammable = null)
{
if (!Resolve(uid, ref flammable))
return;
if (!flammable.OnFire || !_actionBlockerSystem.CanInteract(uid, null) || flammable.Resisting)
return;
flammable.Resisting = true;
_popup.PopupEntity(Loc.GetString("flammable-component-resist-message"), uid, uid);
_stunSystem.TryParalyze(uid, TimeSpan.FromSeconds(2f), true);
// TODO FLAMMABLE: Make this not use TimerComponent...
uid.SpawnTimer(2000, () =>
{
flammable.Resisting = false;
flammable.FireStacks -= flammable.FirestackFade * 10f;
UpdateAppearance(uid, flammable);
});
}
public override void Update(float frameTime)
{
// process all fire events
foreach (var (flammable, deltaTemp) in _fireEvents)
{
// 100 -> 1, 200 -> 2, 400 -> 3...
var fireStackMod = Math.Max(MathF.Log2(deltaTemp / 100) + 1, 0);
var fireStackDelta = fireStackMod - flammable.Comp.FireStacks;
var flammableEntity = flammable.Owner;
if (fireStackDelta > 0)
{
AdjustFireStacks(flammableEntity, fireStackDelta, flammable);
}
Ignite(flammableEntity, flammableEntity, flammable);
}
_fireEvents.Clear();
_timer += frameTime;
if (_timer < UpdateTime)
return;
_timer -= UpdateTime;
// TODO: This needs cleanup to take off the crust from TemperatureComponent and shit.
var query = EntityQueryEnumerator<OnFireComponent>();
while (query.MoveNext(out var uid, out _))
{
if (!TryComp(uid, out FlammableComponent? flammable))
{
RemCompDeferred<OnFireComponent>(uid);
continue;
}
// Slowly dry ourselves off if wet.
if (flammable.FireStacks < 0)
{
flammable.FireStacks = MathF.Min(0, flammable.FireStacks + 1);
}
if (!flammable.OnFire)
{
_alertsSystem.ClearAlert(uid, flammable.FireAlert);
RaiseLocalEvent(uid, new MoodRemoveEffectEvent("OnFire"));
RemCompDeferred<OnFireComponent>(uid);
continue;
}
_alertsSystem.ShowAlert(uid, flammable.FireAlert);
RaiseLocalEvent(uid, new MoodEffectEvent("OnFire"));
if (flammable.FireStacks > 0)
{
var air = _atmosphereSystem.GetContainingMixture(uid);
// If we're in an oxygenless environment, put the fire out.
if (air == null || air.GetMoles(Gas.Oxygen) < 1f)
{
Extinguish(uid, flammable);
continue;
}
var source = EnsureComp<IgnitionSourceComponent>(uid);
_ignitionSourceSystem.SetIgnited((uid, source));
if (TryComp(uid, out TemperatureComponent? temp))
_temperatureSystem.ChangeHeat(uid, 3000 * flammable.FireStacks, false, temp); // WWDP less heat
var multiplier = 1f;
if (!flammable.IgnoreFireProtection)
{
var ev = new GetFireProtectionEvent();
// let the thing on fire handle it
RaiseLocalEvent(uid, ref ev);
// and whatever it's wearing
if (_inventoryQuery.TryComp(uid, out var inv))
_inventory.RelayEvent((uid, inv), ref ev);
multiplier = ev.Multiplier;
}
_damageableSystem.TryChangeDamage(uid, flammable.Damage * flammable.FireStacks * multiplier, interruptsDoAfters: false);
AdjustFireStacks(uid, flammable.FirestackFade * (flammable.Resisting ? 10f : 1f), flammable);
}
else
{
Extinguish(uid, flammable);
}
}
}
}
}