diff --git a/Content.Server/Abilities/Psionics/Abilities/HealOtherPowerSystem.cs b/Content.Server/Abilities/Psionics/Abilities/HealOtherPowerSystem.cs new file mode 100644 index 00000000000..85bae78dc6b --- /dev/null +++ b/Content.Server/Abilities/Psionics/Abilities/HealOtherPowerSystem.cs @@ -0,0 +1,152 @@ +using Robust.Shared.Player; +using Content.Server.DoAfter; +using Content.Shared.Abilities.Psionics; +using Content.Shared.Damage; +using Content.Shared.DoAfter; +using Content.Shared.Popups; +using Content.Shared.Psionics.Events; +using Content.Shared.Examine; +using static Content.Shared.Examine.ExamineSystemShared; +using Robust.Shared.Timing; +using Content.Shared.Actions.Events; +using Robust.Server.Audio; +using Content.Server.Atmos.Rotting; +using Content.Shared.Mobs.Systems; +using Content.Shared.Mobs; +using Content.Shared.Mobs.Components; +using Content.Shared.Psionics.Glimmer; + +namespace Content.Server.Abilities.Psionics; + +public sealed class RevivifyPowerSystem : EntitySystem +{ + [Dependency] private readonly AudioSystem _audioSystem = default!; + [Dependency] private readonly DoAfterSystem _doAfterSystem = default!; + [Dependency] private readonly SharedPopupSystem _popupSystem = default!; + [Dependency] private readonly SharedPsionicAbilitiesSystem _psionics = default!; + [Dependency] private readonly IGameTiming _gameTiming = default!; + [Dependency] private readonly ExamineSystemShared _examine = default!; + [Dependency] private readonly DamageableSystem _damageable = default!; + [Dependency] private readonly RottingSystem _rotting = default!; + [Dependency] private readonly MobThresholdSystem _mobThreshold = default!; + [Dependency] private readonly MobStateSystem _mobState = default!; + [Dependency] private readonly GlimmerSystem _glimmer = default!; + + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnPowerUsed); + SubscribeLocalEvent(OnDispelled); + SubscribeLocalEvent(OnDoAfter); + } + + + private void OnPowerUsed(EntityUid uid, PsionicComponent component, PsionicHealOtherPowerActionEvent args) + { + if (component.DoAfter is not null) + return; + + if (!args.Immediate) + AttemptDoAfter(uid, component, args); + else ActivatePower(uid, component, args); + + if (args.PopupText is not null + && _glimmer.Glimmer > args.GlimmerObviousPopupThreshold * component.CurrentDampening) + _popupSystem.PopupEntity(Loc.GetString(args.PopupText, ("entity", uid)), uid, + Filter.Pvs(uid).RemoveWhereAttachedEntity(entity => !_examine.InRangeUnOccluded(uid, entity, ExamineRange, null)), + true, + args.PopupType); + + if (args.PlaySound + && _glimmer.Glimmer > args.GlimmerObviousSoundThreshold * component.CurrentDampening) + _audioSystem.PlayPvs(args.SoundUse, uid, args.AudioParams); + + // Sanitize the Glimmer inputs because otherwise the game will crash if someone makes MaxGlimmer lower than MinGlimmer. + var minGlimmer = (int) Math.Round(MathF.MinMagnitude(args.MinGlimmer, args.MaxGlimmer) + + component.CurrentAmplification - component.CurrentDampening); + var maxGlimmer = (int) Math.Round(MathF.MaxMagnitude(args.MinGlimmer, args.MaxGlimmer) + + component.CurrentAmplification - component.CurrentDampening); + + _psionics.LogPowerUsed(uid, args.PowerName, minGlimmer, maxGlimmer); + args.Handled = true; + } + + private void AttemptDoAfter(EntityUid uid, PsionicComponent component, PsionicHealOtherPowerActionEvent args) + { + var ev = new PsionicHealOtherDoAfterEvent(_gameTiming.CurTime); + ev.HealingAmount = args.HealingAmount; + ev.RotReduction = args.RotReduction; + ev.DoRevive = args.DoRevive; + var doAfterArgs = new DoAfterArgs(EntityManager, uid, args.UseDelay, ev, uid, target: args.Target) + { + BreakOnUserMove = args.BreakOnUserMove, + BreakOnTargetMove = args.BreakOnTargetMove, + }; + + if (!_doAfterSystem.TryStartDoAfter(doAfterArgs, out var doAfterId)) + return; + + component.DoAfter = doAfterId; + } + + private void OnDispelled(EntityUid uid, PsionicComponent component, DispelledEvent args) + { + if (component.DoAfter is null) + return; + + _doAfterSystem.Cancel(component.DoAfter); + component.DoAfter = null; + args.Handled = true; + } + + private void OnDoAfter(EntityUid uid, PsionicComponent component, PsionicHealOtherDoAfterEvent args) + { + // It's entirely possible for the caster to stop being Psionic(due to mindbreaking) mid cast + if (component is null) + return; + component.DoAfter = null; + + // The target can also cease existing mid-cast + if (args.Target is null) + return; + + _rotting.ReduceAccumulator(args.Target.Value, TimeSpan.FromSeconds(args.RotReduction * component.CurrentAmplification)); + + if (!TryComp(args.Target.Value, out var damageableComponent)) + return; + + _damageable.TryChangeDamage(args.Target.Value, args.HealingAmount * component.CurrentAmplification, true, false, damageableComponent, uid); + + if (!args.DoRevive + || !TryComp(args.Target, out var mob) + || !_mobThreshold.TryGetThresholdForState(args.Target.Value, MobState.Dead, out var threshold) + || damageableComponent.TotalDamage > threshold) + return; + + _mobState.ChangeMobState(args.Target.Value, MobState.Critical, mob, uid); + } + + // This would be the same thing as OnDoAfter, except that here the target isn't nullable, so I have to reuse code with different arguments. + private void ActivatePower(EntityUid uid, PsionicComponent component, PsionicHealOtherPowerActionEvent args) + { + if (component is null) + return; + + _rotting.ReduceAccumulator(args.Target, TimeSpan.FromSeconds(args.RotReduction * component.CurrentAmplification)); + + if (!TryComp(args.Target, out var damageableComponent)) + return; + + _damageable.TryChangeDamage(args.Target, args.HealingAmount * component.CurrentAmplification, true, false, damageableComponent, uid); + + if (!args.DoRevive + || !TryComp(args.Target, out var mob) + || !_mobThreshold.TryGetThresholdForState(args.Target, MobState.Dead, out var threshold) + || damageableComponent.TotalDamage > threshold) + return; + + _mobState.ChangeMobState(args.Target, MobState.Critical, mob, uid); + } +} diff --git a/Content.Shared/Actions/Events/PsionicHealOtherPowerActionEvent.cs b/Content.Shared/Actions/Events/PsionicHealOtherPowerActionEvent.cs new file mode 100644 index 00000000000..8cf11b9e66d --- /dev/null +++ b/Content.Shared/Actions/Events/PsionicHealOtherPowerActionEvent.cs @@ -0,0 +1,59 @@ +using Robust.Shared.Audio; +using Content.Shared.Damage; +using Content.Shared.Popups; + +namespace Content.Shared.Actions.Events; +public sealed partial class PsionicHealOtherPowerActionEvent : EntityTargetActionEvent +{ + [DataField] + public DamageSpecifier HealingAmount = default!; + + [DataField] + public string PowerName; + + /// Controls whether or not a power fires immediately and with no DoAfter + [DataField] + public bool Immediate; + + [DataField] + public string? PopupText; + + [DataField] + public float RotReduction; + + [DataField] + public bool DoRevive; + + [DataField] + public bool BreakOnUserMove = true; + + [DataField] + public bool BreakOnTargetMove = false; + + [DataField] + public float UseDelay = 8f; + + [DataField] + public int MinGlimmer = 8; + + [DataField] + public int MaxGlimmer = 12; + + [DataField] + public int GlimmerObviousSoundThreshold; + + [DataField] + public int GlimmerObviousPopupThreshold; + + [DataField] + public PopupType PopupType = PopupType.Medium; + + [DataField] + public AudioParams AudioParams = default!; + + [DataField] + public bool PlaySound; + + [DataField] + public SoundSpecifier SoundUse = new SoundPathSpecifier("/Audio/Psionics/heartbeat_fast.ogg"); +} diff --git a/Content.Shared/Psionics/Events.cs b/Content.Shared/Psionics/Events.cs index cf9a50c6e18..f110c9c405f 100644 --- a/Content.Shared/Psionics/Events.cs +++ b/Content.Shared/Psionics/Events.cs @@ -1,28 +1,59 @@ using Robust.Shared.Serialization; +using Content.Shared.Damage; using Content.Shared.DoAfter; -namespace Content.Shared.Psionics.Events +namespace Content.Shared.Psionics.Events; + +[Serializable, NetSerializable] +public sealed partial class PsionicRegenerationDoAfterEvent : DoAfterEvent { - [Serializable, NetSerializable] - public sealed partial class PsionicRegenerationDoAfterEvent : DoAfterEvent + [DataField("startedAt", required: true)] + public TimeSpan StartedAt; + + public PsionicRegenerationDoAfterEvent(TimeSpan startedAt) { - [DataField("startedAt", required: true)] - public TimeSpan StartedAt; + StartedAt = startedAt; + } + + public override DoAfterEvent Clone() => this; +} - private PsionicRegenerationDoAfterEvent() - { - } +[Serializable, NetSerializable] +public sealed partial class GlimmerWispDrainDoAfterEvent : SimpleDoAfterEvent { } - public PsionicRegenerationDoAfterEvent(TimeSpan startedAt) - { - StartedAt = startedAt; - } +[Serializable, NetSerializable] +public sealed partial class HealingWordDoAfterEvent : DoAfterEvent +{ + [DataField(required: true)] + public TimeSpan StartedAt; - public override DoAfterEvent Clone() => this; + public HealingWordDoAfterEvent(TimeSpan startedAt) + { + StartedAt = startedAt; } - [Serializable, NetSerializable] - public sealed partial class GlimmerWispDrainDoAfterEvent : SimpleDoAfterEvent + public override DoAfterEvent Clone() => this; +} + +[Serializable, NetSerializable] +public sealed partial class PsionicHealOtherDoAfterEvent : DoAfterEvent +{ + [DataField(required: true)] + public TimeSpan StartedAt; + + [DataField] + public DamageSpecifier HealingAmount = default!; + + [DataField] + public float RotReduction; + + [DataField] + public bool DoRevive; + + public PsionicHealOtherDoAfterEvent(TimeSpan startedAt) { + StartedAt = startedAt; } + + public override DoAfterEvent Clone() => this; } diff --git a/Content.Shared/Psionics/PsionicComponent.cs b/Content.Shared/Psionics/PsionicComponent.cs index 85b7e380fea..37d0a9a7ef4 100644 --- a/Content.Shared/Psionics/PsionicComponent.cs +++ b/Content.Shared/Psionics/PsionicComponent.cs @@ -1,3 +1,4 @@ +using Content.Shared.DoAfter; using Content.Shared.Psionics; using Robust.Shared.GameStates; using Robust.Shared.Prototypes; @@ -166,6 +167,14 @@ private set /// unneccesary subs for unique psionic entities like e.g. Oracle. /// [DataField] - public List? PsychognomicDescriptors = null; + public List? PsychognomicDescriptors = null; + + /// Used for tracking what spell a Psion is actively casting + [DataField] + public DoAfterId? DoAfter; + + /// Popup to play if a Psion attempts to start casting a power while already casting one + [DataField] + public string AlreadyCasting = "already-casting"; } } diff --git a/Resources/Locale/en-US/psionics/psionic-powers.ftl b/Resources/Locale/en-US/psionics/psionic-powers.ftl index c68bb2a4968..01b50cce326 100644 --- a/Resources/Locale/en-US/psionics/psionic-powers.ftl +++ b/Resources/Locale/en-US/psionics/psionic-powers.ftl @@ -1,4 +1,5 @@ generic-power-initialization-feedback = I Awaken. +arleady-casting = I cannot channel more than one power at a time. # Dispel dispel-power-description = Dispel summoned entities such as familiars or forcewalls. @@ -48,6 +49,28 @@ psionic-regeneration-power-initialization-feedback = I look within myself, finding a wellspring of life. psionic-regeneration-power-metapsionic-feedback = {CAPITALIZE($entity)} possesses an overwhelming will to live +# Healing Word +action-name-healing-word = Healing Word +action-description-healing-word = Speak the Lesser Secret Of Life, and restore health to another. +healing-word-power-description = Speak the Lesser Secret Of Life, and restore health to another. +healing-word-power-initialization-feedback = + At the beginning of time, a word was spoken that brought life into the Spheres. + Though it taxes my mind to know it, this Secret is known to me now. + I need only speak it. +healing-word-power-metapsionic-feedback = {CAPITALIZE($entity)} bears the Lesser Secret of Life. +healing-word-begin = {CAPITALIZE($entity)} mutters a word that brings both joy and pain alike to those who hear it. + +# Revivify +action-name-revivify = Breath of Life +action-description-revivify = Speak the Greater Secret of Life, and restore another to life. +revivify-power-description = Speak the Greater Secret of Life, and restore another to life. +revivify-power-initialization-feedback = + For a moment, my soul journeys across time and space to the beginning of it all, there I hear it. + The Secret of Life in its fullness. I feel my entire existence burning out from within, merely by knowing it. + Power flows through me as a mighty river, begging to be released with a simple spoken word. +revivify-power-metapsionic-feedback = {CAPITALIZE($entity)} bears the Greater Secret of Life. +revivify-word-begin = {CAPITALIZE($entity)} enunciates a word of such divine power, that those who hear it weep from joy. + # Telegnosis telegnosis-power-description = Create a telegnostic projection to remotely observe things. telegnosis-power-initialization-feedback = diff --git a/Resources/Prototypes/Actions/psionics.yml b/Resources/Prototypes/Actions/psionics.yml index 981d53884ea..d38608a469b 100644 --- a/Resources/Prototypes/Actions/psionics.yml +++ b/Resources/Prototypes/Actions/psionics.yml @@ -146,3 +146,76 @@ icon: Interface/VerbIcons/psionic_invisibility_off.png event: !type:RemovePsionicInvisibilityOffPowerActionEvent +- type: entity + id: ActionHealingWord + name: action-name-healing-word + description: action-description-healing-word + noSpawn: true + components: + - type: EntityTargetAction + icon: { sprite : Interface/Actions/psionics.rsi, state: healing_word } + useDelay: 10 + checkCanAccess: false + range: 6 + itemIconStyle: BigAction + canTargetSelf: true + blacklist: + components: + - PsionicInsulation + - Mindbroken + event: !type:PsionicHealOtherPowerActionEvent + healingAmount: + groups: # These all get divided by the number of damage types in the group. So they're all -2.5. + Genetic: -2.5 + Toxin: -5 + Airloss: -5 + Brute: -7.5 + Burn: -10 + rotReduction: 10 + useDelay: 1 + doRevive: true + powerName: Healing Word + popupText: healing-word-begin + playSound: true + minGlimmer: 2 + maxGlimmer: 4 + glimmerObviousSoundThreshold: 100 + glimmerObviousPopupThreshold: 200 + +- type: entity + id: ActionRevivify + name: action-name-revivify + description: action-description-revivify + noSpawn: true + components: + - type: EntityTargetAction + icon: { sprite : Interface/Actions/psionics.rsi, state: revivify } + useDelay: 120 + checkCanAccess: false + range: 2 + itemIconStyle: BigAction + canTargetSelf: false + blacklist: + components: + - PsionicInsulation + - Mindbroken + event: !type:PsionicHealOtherPowerActionEvent + healingAmount: + # These all get divided by the number of damage types in the group. So they're all -15 + # Additionally, they're multiplied by the caster's Amplification, which, + # assuming this is the only power they have, the multiplier is between 2.9-3.9 + groups: + Genetic: -15 + Toxin: -30 + Airloss: -60 # Except airloss, which heals 30 per type + Brute: -45 + Burn: -60 + rotReduction: 60 + doRevive: true + powerName: Revivify + popupText: revivify-begin + playSound: true + minGlimmer: 10 # These also get multiplied by caster stats. So, + maxGlimmer: 15 # keeping in mind the ~3.5x multiplier, this spikes glimmer by as much as 60 points. + glimmerObviousSoundThreshold: 50 + glimmerObviousPopupThreshold: 100 diff --git a/Resources/Prototypes/Nyanotrasen/psionicPowers.yml b/Resources/Prototypes/Nyanotrasen/psionicPowers.yml index 0781122b8ec..2aee2273a18 100644 --- a/Resources/Prototypes/Nyanotrasen/psionicPowers.yml +++ b/Resources/Prototypes/Nyanotrasen/psionicPowers.yml @@ -11,3 +11,5 @@ # PsionicInvisibilityPower: 0.15 MindSwapPower: 0.15 TelepathyPower: 1 + HealingWordPower: 0.85 + RevivifyPower: 0.1 diff --git a/Resources/Prototypes/Psionics/psionics.yml b/Resources/Prototypes/Psionics/psionics.yml index 461098eab21..b881f1f11fb 100644 --- a/Resources/Prototypes/Psionics/psionics.yml +++ b/Resources/Prototypes/Psionics/psionics.yml @@ -140,3 +140,25 @@ initializationFeedback: telepathy-power-initialization-feedback metapsionicFeedback: psionic-language-power-feedback # Reuse for telepathy, clairaudience, etc powerSlotCost: 0 + +- type: psionicPower + id: HealingWordPower + name: HealingWord + description: healing-word-power-description + actions: + - ActionHealingWord + initializationFeedback: healing-word-power-initialization-feedback + metapsionicFeedback: healing-word-power-feedback + amplificationModifier: 0.5 + dampeningModifier: 0.5 + +- type: psionicPower + id: RevivifyPower + name: Revivify + description: revivify-power-description + actions: + - ActionRevivify + initializationFeedback: revivify-power-initialization-feedback + metapsionicFeedback: revivify-power-feedback + amplificationModifier: 2.5 # An extremely rare and dangerous power + powerSlotCost: 2 diff --git a/Resources/Prototypes/Roles/Jobs/Civilian/chaplain.yml b/Resources/Prototypes/Roles/Jobs/Civilian/chaplain.yml index c7395ff3e2d..d4f8bdb0671 100644 --- a/Resources/Prototypes/Roles/Jobs/Civilian/chaplain.yml +++ b/Resources/Prototypes/Roles/Jobs/Civilian/chaplain.yml @@ -5,8 +5,8 @@ playTimeTracker: JobChaplain requirements: - !type:CharacterDepartmentTimeRequirement - department: Epistemics # DeltaV - Epistemics Department replacing Science - min: 14400 #DeltaV 4 hours + department: Epistemics # Chaplain is now one of the station's "Crew-Aligned Wizards" + min: 14400 # 4 hours - !type:CharacterLogicOrRequirement requirements: - !type:CharacterSpeciesRequirement @@ -21,7 +21,7 @@ supervisors: job-supervisors-rd access: - Chapel - - Research # DeltaV - Move Chaplain into Epistemics + - Research - Maintenance special: - !type:AddComponentSpecial @@ -32,6 +32,7 @@ - type: InnatePsionicPowers powersToAdd: - TelepathyPower + - HealingWordPower - type: startingGear id: ChaplainGear @@ -40,7 +41,7 @@ back: ClothingBackpackChaplainFilled shoes: ClothingShoesColorBlack id: ChaplainPDA - ears: ClothingHeadsetScience # DeltaV - Move Chaplain into Epistemics + ears: ClothingHeadsetScience innerClothingSkirt: ClothingUniformJumpskirtChaplain satchel: ClothingBackpackSatchelChaplainFilled duffelbag: ClothingBackpackDuffelChaplainFilled diff --git a/Resources/Textures/Interface/Actions/psionics.rsi/healing_word.png b/Resources/Textures/Interface/Actions/psionics.rsi/healing_word.png new file mode 100644 index 00000000000..800e2a46e61 Binary files /dev/null and b/Resources/Textures/Interface/Actions/psionics.rsi/healing_word.png differ diff --git a/Resources/Textures/Interface/Actions/psionics.rsi/meta.json b/Resources/Textures/Interface/Actions/psionics.rsi/meta.json new file mode 100644 index 00000000000..085cc412814 --- /dev/null +++ b/Resources/Textures/Interface/Actions/psionics.rsi/meta.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "license": "CC-BY-SA-3.0", + "copyright": "Created by leonardo_dabepis (discord)", + "size": { + "x": 64, + "y": 64 + }, + "states": [ + { + "name": "healing_word" + }, + { + "name": "revivify" + } + ] +} diff --git a/Resources/Textures/Interface/Actions/psionics.rsi/revivify.png b/Resources/Textures/Interface/Actions/psionics.rsi/revivify.png new file mode 100644 index 00000000000..087f40b4c98 Binary files /dev/null and b/Resources/Textures/Interface/Actions/psionics.rsi/revivify.png differ diff --git a/Resources/Textures/Interface/VerbIcons/ATTRIBUTION.txt b/Resources/Textures/Interface/VerbIcons/ATTRIBUTION.txt index 0b8ae856bd8..69aa4650b32 100644 --- a/Resources/Textures/Interface/VerbIcons/ATTRIBUTION.txt +++ b/Resources/Textures/Interface/VerbIcons/ATTRIBUTION.txt @@ -7,3 +7,6 @@ https://game-icons.net/1x1/lorc/padlock.html unlock.svg by Delapouite under CC BY 3.0 https://game-icons.net/1x1/delapouite/padlock-open.html + +healing_word.png by LeonardoDaBepis under CC BY 3.0 +revivify.png by LeonardoDaBepis under CC BY 3.0 \ No newline at end of file