Files
wwdpublic/Content.Server/Body/Systems/RespiratorSystem.cs
RedFoxIV daf4f66414 "Proper" "Softcrit" "Support" (#1545)
# Description

Implements the softcrit functionality.
Similiar to critical state but spessmen will be able to communicate and
crawl around, but not pick up items.
Also supports configuring what is and isn't allowed in different
MobStates (per mob prototype): you can enable picking up items while in
softcrit so people can pick up their lasgun and continue shooting after
taking a 40x46mm to their ass cheeks from the guest nukies while being
dragged to safety.

![escape-from-tarkov-raid](https://github.com/user-attachments/assets/7f31702d-5677-4daf-a13d-8a9525fd3f9f)

<details> <summary><h1>Technical details</h1></summary>
New prototype type: "mobStateParams" (`MobStateParametersPrototype`)
Used to specify what can and can't be done when in a certain mobstate.
Of note that they are not actually bound to any `MobState` by
themselves. To assign a params prototype to a mobstate, use
`InitMobStateParams` in `MobStateComponent`.
It has to be a prototype because if I just did something akin to
`Dictionary<MobState, Dictionary<string, bool>>`, you'd have to check
the parent and copy every flag besides the one you wish to modify. That
is, if I understand how the prototype system works correctly, which I
frankly doubt. <!-- Working on softcrit made me hate prototypes. -->

MobStateComponent now has:
- `Dictionary<string, string> InitMobStateParams`, for storing "mobstate
- parameter prototype" pairs. `<string, string>` because it has to be
editable via mob prototypes. Named "mobStateParams" for mob prototypes.
- `public Dictionary<MobState, MobStateParametersPrototype>
MobStateParams` for actually storing the params for each state
- `public Dictionary<MobState, MobStateParametersOverride>
MobStateParamsOverrides` for storing overrides.
`MobStateParametersOverride` is a struct which mirrors all
`MobStateParametersPrototype`'s fields, except they're all nullable.
This is meant for code which wants to temporarily override some setting,
like a spell which allows dead people to talk. This is not the best
solution, but it should do at first. A better option would be tracking
each change separately, instead of hoping different systems overriding
the same flag will play nicely with eachother.
- a shitton of getter methods

TraitModifyMobState now has:
- `public Dictionary<string, string> Params` to specify a new prototype
to use.
- Important note: All values of `MobStateParametersPrototype` are
nullable, which is a hack to support `TraitModifyMobState`. This trait
takes one `MobStateParametersPrototype` per mobstate and applies all of
its non-null values. This way, a params prototype can be created which
will only have `pointing: true` and the trait can apply it (e.g. to
critstate, so we can spam pointing while dying like it's a game of turbo
dota)
- The above is why that wall of getters exists: They check the relevant
override struct, then the relevant prototype. If both are null, they
default to false (0f for floats.) The only exception is
OxyDamageOverlay, because it's used both for oxy damage overlay (if
null) and as a vision-limiting black void in crit..

MobStateSystem now has:
- a bunch of new "IsSomething"/"CanDoSomething" methods to check the
various flags, alongside rewritten old ones.
-
![image](https://github.com/user-attachments/assets/33a6b296-c12c-4311-9abe-90ca4288e871)
lookin ahh predicate factory

</details>
---

# TODO

done:
- [x] Make proper use of `MobStateSystem.IsIncapacitated()`.
done: some checks were changed, some left as they did what was (more or
less) intended.
<details>Previous `IsIncapacitated()` implementation simply checked if
person was in crit or dead. Now there is a `IsIncapacitated` flag in the
parameters, but it's heavily underutilized. I may need some help on this
one, since I don't know where would be a good place to check for it and
I absolutely will not just scour the entire build in search for them.
</details>

- [x] Separate force-dropping items from being downed
done: dropItemsOnEntering bool field. If true, will drop items upon
entering linked mobstate.
- [x] Don't drop items if `ForceDown` is true but `PickingUp` is also
true.
done: dropItemsOnEntering bool field. If true, will drop items upon
entering linked mobstate.
- [x] Actually check what are "conscious attempts" are used for
done: whether or not mob is conscious. Renamed the bool field
accordingly.
- [x] Look into adding a way to make people choke "slowly" in softcrit
as opposed to choking at "regular speed" in crit. Make that into a param
option? Make that into a float so the speed can be finetuned?
done: `BreathingMultiplier` float field added.
<details>
1f is regular breathing, 0.25 is "quarter-breathing". Air taken is
multiplied by `BreathingMultiplier` and suffocation damage taken (that
is dealt by RespiratorSystem, not all oxy damage) is multiplied by
`1-BreathingMultiplier`.
</details>

- [x] make sure the serializer actually does its job
done: it doesn't. Removed.
- [x] Make an option to prohibit using radio headsets while in softcrit
done: Requires Incapacitated parameter to be false to be able to use
headset radio.
- [x] Make sure it at least compiles

not done:
- [ ] probably move some other stuff to Params if it makes sense. Same
thing as with `IsIncapacitated` though: I kinda don't want to, at least
for now.

---

<details><summary><h1>No media</h1></summary>
<p>

:p

</p>
</details>

---

# Changelog

🆑
- add: Soft critical state. Crawl to safety, or to your doom - whatever
is closer.

---------

Signed-off-by: RedFoxIV <38788538+RedFoxIV@users.noreply.github.com>
Co-authored-by: VMSolidus <evilexecutive@gmail.com>

(cherry picked from commit 9a357c1774f1a783844a07b5414f504ca574d84c)
2025-02-15 00:12:50 +03:00

368 lines
13 KiB
C#

using Content.Server.Administration.Logs;
using Content.Server.Atmos.EntitySystems;
using Content.Server.Body.Components;
using Content.Server.Chat.Systems;
using Content.Server.Chemistry.Containers.EntitySystems;
using Content.Server.EntityEffects.EffectConditions;
using Content.Server.EntityEffects.Effects;
using Content.Server.Popups;
using Content.Shared.Alert;
using Content.Shared.Atmos;
using Content.Shared.Body.Components;
using Content.Shared._Shitmed.Body.Organ;
using Content.Shared.Body.Prototypes;
using Content.Shared.Chemistry.Components;
using Content.Shared.Chemistry.Reagent; // Shitmed Change
using Content.Shared.Damage;
using Content.Shared.Database;
using Content.Shared.EntityEffects;
using Content.Shared.Mobs.Systems;
using Content.Shared.Mood;
using JetBrains.Annotations;
using Robust.Shared.Prototypes;
using Robust.Shared.Timing;
using Content.Shared.Mobs.Components;
namespace Content.Server.Body.Systems;
[UsedImplicitly]
public sealed class RespiratorSystem : EntitySystem
{
[Dependency] private readonly IAdminLogManager _adminLogger = default!;
[Dependency] private readonly IGameTiming _gameTiming = default!;
[Dependency] private readonly AlertsSystem _alertsSystem = default!;
[Dependency] private readonly AtmosphereSystem _atmosSys = default!;
[Dependency] private readonly BodySystem _bodySystem = default!;
[Dependency] private readonly DamageableSystem _damageableSys = default!;
[Dependency] private readonly LungSystem _lungSystem = default!;
[Dependency] private readonly PopupSystem _popupSystem = default!;
[Dependency] private readonly MobStateSystem _mobState = default!;
[Dependency] private readonly IPrototypeManager _protoMan = default!;
[Dependency] private readonly SolutionContainerSystem _solutionContainerSystem = default!;
[Dependency] private readonly ChatSystem _chat = default!;
private static readonly ProtoId<MetabolismGroupPrototype> GasId = new("Gas");
public override void Initialize()
{
base.Initialize();
// We want to process lung reagents before we inhale new reagents.
UpdatesAfter.Add(typeof(MetabolizerSystem));
SubscribeLocalEvent<RespiratorComponent, MapInitEvent>(OnMapInit);
SubscribeLocalEvent<RespiratorComponent, EntityUnpausedEvent>(OnUnpaused);
SubscribeLocalEvent<RespiratorComponent, ApplyMetabolicMultiplierEvent>(OnApplyMetabolicMultiplier);
}
private void OnMapInit(Entity<RespiratorComponent> ent, ref MapInitEvent args)
{
ent.Comp.NextUpdate = _gameTiming.CurTime + ent.Comp.UpdateInterval;
}
private void OnUnpaused(Entity<RespiratorComponent> ent, ref EntityUnpausedEvent args)
{
ent.Comp.NextUpdate += args.PausedTime;
}
public override void Update(float frameTime)
{
base.Update(frameTime);
var query = EntityQueryEnumerator<RespiratorComponent, BodyComponent>();
while (query.MoveNext(out var uid, out var respirator, out var body))
{
if (_gameTiming.CurTime < respirator.NextUpdate)
continue;
respirator.NextUpdate += respirator.UpdateInterval;
if (_mobState.IsDead(uid))
continue;
if (HasComp<RespiratorImmuneComponent>(uid))
continue;
UpdateSaturation(uid, -(float) respirator.UpdateInterval.TotalSeconds, respirator);
float mul = _mobState.BreatheMultiplier(uid);
if (mul > 0f && !HasComp<DebrainedComponent>(uid)) // Shitmed: cannot breathe in crit or when no brain.
{
switch (respirator.Status)
{
case RespiratorStatus.Inhaling:
Inhale(uid, body, mul);
respirator.Status = RespiratorStatus.Exhaling;
break;
case RespiratorStatus.Exhaling:
Exhale(uid, body);
respirator.Status = RespiratorStatus.Inhaling;
break;
}
}
if (respirator.Saturation < respirator.SuffocationThreshold)
{
if (_gameTiming.CurTime >= respirator.LastGaspPopupTime + respirator.GaspPopupCooldown)
{
respirator.LastGaspPopupTime = _gameTiming.CurTime;
_popupSystem.PopupEntity(Loc.GetString("lung-behavior-gasp"), uid);
}
TakeSuffocationDamage((uid, respirator), 1 - mul);
respirator.SuffocationCycles += 1;
continue;
}
StopSuffocation((uid, respirator));
respirator.SuffocationCycles = 0;
}
}
public void Inhale(EntityUid uid, BodyComponent? body = null, float breathVolumeMultiplier = 1f)
{
if (!Resolve(uid, ref body, logMissing: false))
return;
var organs = _bodySystem.GetBodyOrganComponents<LungComponent>(uid, body);
// Inhale gas
var ev = new InhaleLocationEvent();
RaiseLocalEvent(uid, ref ev);
ev.Gas ??= _atmosSys.GetContainingMixture(uid, excite: true);
if (ev.Gas is null)
{
return;
}
var actualGas = ev.Gas.RemoveVolume(Atmospherics.BreathVolume * breathVolumeMultiplier);
var lungRatio = 1.0f / organs.Count;
var gas = organs.Count == 1 ? actualGas : actualGas.RemoveRatio(lungRatio);
foreach (var (lung, _) in organs)
{
// Merge doesn't remove gas from the giver.
_atmosSys.Merge(lung.Air, gas);
_lungSystem.GasToReagent(lung.Owner, lung);
}
}
public void Exhale(EntityUid uid, BodyComponent? body = null)
{
if (!Resolve(uid, ref body, logMissing: false))
return;
var organs = _bodySystem.GetBodyOrganComponents<LungComponent>(uid, body);
// exhale gas
var ev = new ExhaleLocationEvent();
RaiseLocalEvent(uid, ref ev, broadcast: false);
if (ev.Gas is null)
{
ev.Gas = _atmosSys.GetContainingMixture(uid, excite: true);
// Walls and grids without atmos comp return null. I guess it makes sense to not be able to exhale in walls,
// but this also means you cannot exhale on some grids.
ev.Gas ??= GasMixture.SpaceGas;
}
var outGas = new GasMixture(ev.Gas.Volume);
foreach (var (lung, _) in organs)
{
_atmosSys.Merge(outGas, lung.Air);
lung.Air.Clear();
if (_solutionContainerSystem.ResolveSolution(lung.Owner, lung.SolutionName, ref lung.Solution))
_solutionContainerSystem.RemoveAllSolution(lung.Solution.Value);
}
_atmosSys.Merge(ev.Gas, outGas);
}
/// <summary>
/// Check whether or not an entity can metabolize inhaled air without suffocating or taking damage (i.e., no toxic
/// gasses).
/// </summary>
public bool CanMetabolizeInhaledAir(Entity<RespiratorComponent?> ent)
{
if (!Resolve(ent, ref ent.Comp))
return false;
var ev = new InhaleLocationEvent();
RaiseLocalEvent(ent, ref ev);
var gas = ev.Gas ?? _atmosSys.GetContainingMixture(ent.Owner);
if (gas == null)
return false;
return CanMetabolizeGas(ent, gas);
}
/// <summary>
/// Check whether or not an entity can metabolize the given gas mixture without suffocating or taking damage
/// (i.e., no toxic gasses).
/// </summary>
public bool CanMetabolizeGas(Entity<RespiratorComponent?> ent, GasMixture gas)
{
if (!Resolve(ent, ref ent.Comp))
return false;
var organs = _bodySystem.GetBodyOrganComponents<LungComponent>(ent);
if (organs.Count == 0)
return false;
gas = new GasMixture(gas);
var lungRatio = 1.0f / organs.Count;
gas.Multiply(MathF.Min(lungRatio * gas.Volume/Atmospherics.BreathVolume, lungRatio));
var solution = _lungSystem.GasToReagent(gas);
float saturation = 0;
foreach (var organ in organs)
{
saturation += GetSaturation(solution, organ.Comp.Owner, out var toxic);
if (toxic)
return false;
}
return saturation > ent.Comp.UpdateInterval.TotalSeconds;
}
/// <summary>
/// Get the amount of saturation that would be generated if the lung were to metabolize the given solution.
/// </summary>
/// <remarks>
/// This assumes the metabolism rate is unbounded, which generally should be the case for lungs, otherwise we get
/// back to the old pulmonary edema bug.
/// </remarks>
/// <param name="solution">The reagents to metabolize</param>
/// <param name="lung">The entity doing the metabolizing</param>
/// <param name="toxic">Whether or not any of the reagents would deal damage to the entity</param>
private float GetSaturation(Solution solution, Entity<MetabolizerComponent?> lung, out bool toxic)
{
toxic = false;
if (!Resolve(lung, ref lung.Comp))
return 0;
if (lung.Comp.MetabolismGroups == null)
return 0;
float saturation = 0;
foreach (var (id, quantity) in solution.Contents)
{
var reagent = _protoMan.Index<ReagentPrototype>(id.Prototype);
if (reagent.Metabolisms == null)
continue;
if (!reagent.Metabolisms.TryGetValue(GasId, out var entry))
continue;
foreach (var effect in entry.Effects)
{
if (effect is HealthChange health)
toxic |= CanMetabolize(health) && health.Damage.AnyPositive();
else if (effect is Oxygenate oxy && CanMetabolize(oxy))
saturation += oxy.Factor * quantity.Float();
}
}
// TODO generalize condition checks
// this is pretty janky, but I just want to bodge a method that checks if an entity can breathe a gas mixture
// Applying actual reaction effects require a full ReagentEffectArgs struct.
bool CanMetabolize(EntityEffect effect)
{
if (effect.Conditions == null)
return true;
foreach (var cond in effect.Conditions)
{
if (cond is OrganType organ && !organ.Condition(lung, EntityManager))
return false;
}
return true;
}
return saturation;
}
private void TakeSuffocationDamage(Entity<RespiratorComponent> ent, float damageMultiplier = 1f)
{
if (ent.Comp.SuffocationCycles == 2)
_adminLogger.Add(LogType.Asphyxiation, $"{ToPrettyString(ent):entity} started suffocating");
if (ent.Comp.SuffocationCycles >= ent.Comp.SuffocationCycleThreshold)
{
// TODO: This is not going work with multiple different lungs, if that ever becomes a possibility
var organs = _bodySystem.GetBodyOrganComponents<LungComponent>(ent);
foreach (var (comp, _) in organs)
{
_alertsSystem.ShowAlert(ent, comp.Alert);
}
RaiseLocalEvent(ent, new MoodEffectEvent("Suffocating"));
}
var damageToTake = ent.Comp.Damage;
if (HasComp<DebrainedComponent>(ent))
damageToTake *= 4.5f;
damageToTake *= damageMultiplier;
_damageableSys.TryChangeDamage(ent, damageToTake, interruptsDoAfters: false);
}
private void StopSuffocation(Entity<RespiratorComponent> ent)
{
if (ent.Comp.SuffocationCycles >= 2)
_adminLogger.Add(LogType.Asphyxiation, $"{ToPrettyString(ent):entity} stopped suffocating");
// TODO: This is not going work with multiple different lungs, if that ever becomes a possibility
var organs = _bodySystem.GetBodyOrganComponents<LungComponent>(ent);
foreach (var (comp, _) in organs)
{
_alertsSystem.ClearAlert(ent, comp.Alert);
}
_damageableSys.TryChangeDamage(ent, ent.Comp.DamageRecovery);
}
public void UpdateSaturation(EntityUid uid, float amount,
RespiratorComponent? respirator = null)
{
if (!Resolve(uid, ref respirator, false))
return;
respirator.Saturation += amount;
respirator.Saturation =
Math.Clamp(respirator.Saturation, respirator.MinSaturation, respirator.MaxSaturation);
}
private void OnApplyMetabolicMultiplier(
Entity<RespiratorComponent> ent,
ref ApplyMetabolicMultiplierEvent args)
{
// TODO REFACTOR THIS
// This will slowly drift over time due to floating point errors.
// Instead, raise an event with the base rates and allow modifiers to get applied to it.
if (args.Apply)
{
ent.Comp.UpdateInterval *= args.Multiplier;
ent.Comp.Saturation *= args.Multiplier;
ent.Comp.MaxSaturation *= args.Multiplier;
ent.Comp.MinSaturation *= args.Multiplier;
return;
}
// This way we don't have to worry about it breaking if the stasis bed component is destroyed
ent.Comp.UpdateInterval /= args.Multiplier;
ent.Comp.Saturation /= args.Multiplier;
ent.Comp.MaxSaturation /= args.Multiplier;
ent.Comp.MinSaturation /= args.Multiplier;
}
}
[ByRefEvent]
public record struct InhaleLocationEvent(GasMixture? Gas);
[ByRefEvent]
public record struct ExhaleLocationEvent(GasMixture? Gas);