From ce8f0616d3b6c289f160db2f2ca66306fbab9932 Mon Sep 17 00:00:00 2001 From: Dragometh Date: Fri, 19 Jun 2026 03:57:57 -0300 Subject: [PATCH] health examinable component --- .../_CE/Health/CESharedDamageableSystem.cs | 8 +- .../CEHealthExaminableComponent.cs | 21 ++++ .../CEHealthExaminableSystem.cs | 101 ++++++++++++++++++ .../health-examinable-default.ftl | 11 ++ .../Prototypes/_CE/Entities/Mobs/base.yml | 2 + 5 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 Content.Shared/_CE/HealthExaminable/CEHealthExaminableComponent.cs create mode 100644 Content.Shared/_CE/HealthExaminable/CEHealthExaminableSystem.cs create mode 100644 Resources/Locale/en-US/_CE/health-examinable/health-examinable-default.ftl diff --git a/Content.Shared/_CE/Health/CESharedDamageableSystem.cs b/Content.Shared/_CE/Health/CESharedDamageableSystem.cs index 1c2a6a1a07..12cc2326ab 100644 --- a/Content.Shared/_CE/Health/CESharedDamageableSystem.cs +++ b/Content.Shared/_CE/Health/CESharedDamageableSystem.cs @@ -302,11 +302,17 @@ public CEHealthInfo GetHealthInfo(EntityUid uid) remainingUntilDeath = Math.Max(0, maxHp + destr.DestroyThreshold - damage.Damage.Total); } + float ratio = -1; + if (!mobState.Critical) // if mob isn't crit we use currentHp + ratio = maxHp > 0 ? Math.Clamp((float)currentHp / maxHp, 0f, 1f) : 0f; + else if (mobState.Critical && destroyThreshold != null) // if he is crit, though, we use damage total + ratio = Math.Clamp(1f - ((float)damage.Damage.Total - maxHp) / destroyThreshold.Value, 0f, 1f); + return new CEHealthInfo { CurrentHp = currentHp, MaxHp = maxHp, - Ratio = maxHp > 0 ? Math.Clamp((float) currentHp / maxHp, 0f, 1f) : 0f, + Ratio = ratio, Critical = mobState.Critical, HasMobState = true, DestroyThreshold = destroyThreshold, diff --git a/Content.Shared/_CE/HealthExaminable/CEHealthExaminableComponent.cs b/Content.Shared/_CE/HealthExaminable/CEHealthExaminableComponent.cs new file mode 100644 index 0000000000..6a2aadae4e --- /dev/null +++ b/Content.Shared/_CE/HealthExaminable/CEHealthExaminableComponent.cs @@ -0,0 +1,21 @@ +using Content.Shared._CE.Health.Prototypes; +using Content.Shared.FixedPoint; +using Robust.Shared.Prototypes; + +namespace Content.Shared._CE.HealthExaminable; + +[RegisterComponent, Access(typeof(CEHealthExaminableComponent))] +public sealed partial class CEHealthExaminableComponent : Component +{ + // REVIEW: could be interesting to expose this to prototypes; would allow for different thresholds for different mobs. + public List Thresholds = new() + { FixedPoint2.New(100), FixedPoint2.New(90), FixedPoint2.New(70), FixedPoint2.New(40), FixedPoint2.New(10) }; + + /// + /// Health examine text is automatically generated through creating loc string IDs, in the form: + /// `health-examinable-[prefix]-[type]-[threshold]` + /// This part determines the prefix. + /// + [DataField] + public string LocPrefix = "default"; +} diff --git a/Content.Shared/_CE/HealthExaminable/CEHealthExaminableSystem.cs b/Content.Shared/_CE/HealthExaminable/CEHealthExaminableSystem.cs new file mode 100644 index 0000000000..8f3c195664 --- /dev/null +++ b/Content.Shared/_CE/HealthExaminable/CEHealthExaminableSystem.cs @@ -0,0 +1,101 @@ +using Content.Shared._CE.Health; +using Content.Shared._CE.Health.Components; +using Content.Shared.Damage.Components; +using Content.Shared.Examine; +using Content.Shared.FixedPoint; +using Content.Shared.IdentityManagement; +using Content.Shared.Verbs; +using Robust.Shared.Utility; + +namespace Content.Shared._CE.HealthExaminable; + +public sealed partial class CEHealthExaminableSystem : EntitySystem +{ + [Dependency] private ExamineSystemShared _examineSystem = default!; + [Dependency] private CESharedDamageableSystem _damageable = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent>(OnGetExamineVerbs); + } + + private void OnGetExamineVerbs(EntityUid uid, CEHealthExaminableComponent component, GetVerbsEvent args) + { + // as to not show health examine verbs if target is not damageable + if (!TryComp(uid, out var _)) + return; + + var detailsRange = _examineSystem.IsInDetailsRange(args.User, uid); + + var verb = new ExamineVerb() + { + Act = () => + { + var markup = CreateMarkup(uid, component); + _examineSystem.SendExamineTooltip(args.User, uid, markup, false, false); + }, + Text = Loc.GetString("health-examinable-verb-text"), + Category = VerbCategory.Examine, + Disabled = !detailsRange, + Message = detailsRange ? null : Loc.GetString("health-examinable-verb-disabled"), + Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/VerbIcons/rejuvenate.svg.192dpi.png")) + }; + + args.Verbs.Add(verb); + } + + public FormattedMessage CreateMarkup(EntityUid uid, CEHealthExaminableComponent component) + { + var msg = new FormattedMessage(); + var baseLocStr = $"health-examinable-{component.LocPrefix}"; + + var healthInfo = _damageable.GetHealthInfo(uid); + + // REVIEW: Will this component be applied to structures/destructibles too? If so, then this entire method will need a few changes. + if (!healthInfo.HasMobState) + return msg; + + var closest = FixedPoint2.MaxValue; + var chosenLocStr = string.Empty; + var ratio = healthInfo.Ratio * 100; + + baseLocStr = $"{baseLocStr}-{(healthInfo.Critical ? "critical-" : "")}"; + + foreach (var threshold in component.Thresholds) + { + var str = $"{baseLocStr}{threshold}"; + var tempLocStr = Loc.GetString(str, ("target", Identity.Entity(uid, EntityManager))); + + // string doesn't exist in localization (loc.getstring returns the used messageId; in this case, str) + if (tempLocStr == str) + continue; + + if (ratio <= threshold && threshold <= closest) + { + chosenLocStr = tempLocStr; + closest = threshold; + } + } + msg.AddMarkupOrThrow(chosenLocStr); + + if (msg.IsEmpty) + msg.AddMarkupOrThrow(Loc.GetString($"health-examinable-{component.LocPrefix}-100")); // use 100% health in case somehow the message came up empty + + // Anything else want to add on to this? + RaiseLocalEvent(uid, new CEHealthBeingExaminedEvent(msg), true); + + return msg; + } +} + +/// +/// A class raised on an entity whose health is being examined +/// in order to add special text that is not handled by the +/// damage thresholds. +/// +public sealed class CEHealthBeingExaminedEvent(FormattedMessage message) +{ + public FormattedMessage Message = message; +} diff --git a/Resources/Locale/en-US/_CE/health-examinable/health-examinable-default.ftl b/Resources/Locale/en-US/_CE/health-examinable/health-examinable-default.ftl new file mode 100644 index 0000000000..1736978590 --- /dev/null +++ b/Resources/Locale/en-US/_CE/health-examinable/health-examinable-default.ftl @@ -0,0 +1,11 @@ +health-examinable-default-100 = There are no obvious wounds to be seen. +health-examinable-default-90 = [color=#FFDDDD]{ CAPITALIZE(SUBJECT($target)) } { CONJUGATE-HAVE($target) } a few small wounds.[/color] +health-examinable-default-70 = [color=#FFBBBB]{ CAPITALIZE(SUBJECT($target)) } { CONJUGATE-HAVE($target) } some shallow wounds.[/color] +health-examinable-default-40 = [color=#FF9999]{ CAPITALIZE(SUBJECT($target)) } { CONJUGATE-HAVE($target) } serious, deep wounds.[/color] +health-examinable-default-10 = [color=#FF7777]{ CAPITALIZE(SUBJECT($target)) } { CONJUGATE-BE($target) } badly beaten up!.[/color] + +health-examinable-default-critical-100 = [color=#FF6666]{ CAPITALIZE(POSS-ADJ($target)) } body is disfigured and extensively wounded.[/color] +health-examinable-default-critical-90 = [color=#FF5555]{ CAPITALIZE(POSS-ADJ($target)) } body is disfigured and extensively wounded, with slightly pale skin.[/color] +health-examinable-default-critical-70 = [color=#FF4444]{ CAPITALIZE(POSS-ADJ($target)) } body is disfigured and extensively wounded, with pale skin. [/color] +health-examinable-default-critical-40 = [color=#FF2020]{ CAPITALIZE(POSS-ADJ($target)) } body is disfigured and extensively wounded, with very pale skin. [/color] +health-examinable-default-critical-10 = [color=#FF0000]{ CAPITALIZE(SUBJECT($target)) } { CONJUGATE-BE($target) } blanking and seems about to pass out![/color] diff --git a/Resources/Prototypes/_CE/Entities/Mobs/base.yml b/Resources/Prototypes/_CE/Entities/Mobs/base.yml index a56a272b8f..38803e02f5 100644 --- a/Resources/Prototypes/_CE/Entities/Mobs/base.yml +++ b/Resources/Prototypes/_CE/Entities/Mobs/base.yml @@ -5,6 +5,8 @@ components: - type: CEDamageable - type: CEDamagePopup + - type: CEHealthExaminable + locPrefix: default - type: CEDestructible destroyThreshold: 100 destroySound: