Skip to content

Commit

Permalink
Psionic Familiar System (#1146)
Browse files Browse the repository at this point in the history
# Description

This PR Implements a new dedicated "Psionic Familiar System", allowing a
new class of powers for summoning Familiars directly. Familiars are
Psionic entities called from other planes of existence, who will follow
and protect the caster, with their life if necessary. Familiars can
never hurt their summoner, and inherit all of the faction tags of their
summoner. When the summoner dies or is mindbroken, his Familiars are
"sent back from whence they came".

Notably, Psionic Familiars are not like Remilia or the Ifrit, they do
not **depend** on being player controlled ghostroles, although they can
be! If not player controlled, they instead function as an NPC, similarly
to a Rat King's Servants.

# TODO

- [X] Implement new familiars for this system to use
- [X] Get sprites ready for the summon familiar spells.
- [x] Move Remilia and the Ifrit out of their Bible, and into the new
Psionic Familiar System.

<details><summary><h1>Media</h1></summary>
<p>


https://github.com/user-attachments/assets/8e89021b-3529-4b48-9909-7e8bfd72d587


![image](https://github.com/user-attachments/assets/f89cbcc5-fb43-423a-a571-8aed9a5cfbba)

</p>
</details>

# Changelog

:cl:
- add: A new class of Psionic powers has been added, "Summon Familiar".
Familiars are a new kind of Psionic creature that can be summoned by
Psions with the right power. Familiars will automatically follow their
Master, attack anyone who attacks their Master, fight back when
attacked, and attack anyone their Master attacks. Additionally,
Familiars are also ghostroles, so that they can be taken over by a
player, but otherwise do not require player control to function.
Familiars disappear when they die, and will also disappear if their
Master is either killed, or mindbroken. Psions can have a maximum of
one(1) familiar at a time.
- add: New psi-power "Summon Imp", as the first new Psi Familiar. Imps
are small motes of living flame that follow and protect their summoner.
Imps also emit an incredibly bright light, and can natively cast
Pyrokinetic Flare.
- add: Remilia has been updated to be a Psi Familiar, and no longer
requires the Bible to summon. Chaplains now start with a Power that lets
them summon Remilia once every 2 minutes.

---------

Signed-off-by: VMSolidus <[email protected]>
Co-authored-by: Remuchi <[email protected]>
  • Loading branch information
VMSolidus and Remuchi authored Oct 27, 2024
1 parent 678875b commit cfcc4cb
Show file tree
Hide file tree
Showing 23 changed files with 647 additions and 28 deletions.
19 changes: 18 additions & 1 deletion Content.Server/Abilities/Psionics/PsionicAbilitiesSystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
using System.Linq;
using Robust.Server.Player;
using Content.Server.Chat.Managers;
using Content.Server.Psionics.Glimmer;

namespace Content.Server.Abilities.Psionics
{
Expand All @@ -29,6 +28,7 @@ public sealed class PsionicAbilitiesSystem : EntitySystem
[Dependency] private readonly ISerializationManager _serialization = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IChatManager _chatManager = default!;
[Dependency] private readonly PsionicFamiliarSystem _psionicFamiliar = default!;

private ProtoId<WeightedRandomPrototype> _pool = "RandomPsionicPowerPool";
private const string GenericInitializationMessage = "generic-power-initialization-feedback";
Expand Down Expand Up @@ -59,6 +59,7 @@ private void OnPsionicShutdown(EntityUid uid, PsionicComponent component, Compon
|| HasComp<MindbrokenComponent>(uid))
return;

KillFamiliars(component);
RemoveAllPsionicPowers(uid);
}

Expand Down Expand Up @@ -215,6 +216,7 @@ public void RemoveAllPsionicPowers(EntityUid uid, bool mindbreak = false)

_popups.PopupEntity(Loc.GetString(psionic.MindbreakingFeedback, ("entity", MetaData(uid).EntityName)), uid, uid, PopupType.MediumCaution);

KillFamiliars(psionic);
RemComp<PsionicComponent>(uid);
RemComp<InnatePsionicPowersComponent>(uid);

Expand Down Expand Up @@ -368,5 +370,20 @@ private void RemovePsionicStatSources(EntityUid uid, PsionicPowerPrototype proto

RefreshPsionicModifiers(uid, psionic);
}

private void KillFamiliars(PsionicComponent component)
{
if (component.Familiars.Count <= 0)
return;

foreach (var familiar in component.Familiars)
{
if (!TryComp<PsionicFamiliarComponent>(familiar, out var familiarComponent)
|| !familiarComponent.DespawnOnMasterDeath)
continue;

_psionicFamiliar.DespawnFamiliar(familiar, familiarComponent);
}
}
}
}
140 changes: 140 additions & 0 deletions Content.Server/Abilities/Psionics/PsionicFamiliarSystem.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
using Content.Server.NPC;
using Content.Server.NPC.Components;
using Content.Server.NPC.HTN;
using Content.Server.NPC.Systems;
using Content.Server.Popups;
using Content.Shared.Abilities.Psionics;
using Content.Shared.Actions.Events;
using Content.Shared.Interaction.Events;
using Content.Shared.Mobs;
using Robust.Shared.Map;
using System.Numerics;

namespace Content.Server.Abilities.Psionics;

public sealed partial class PsionicFamiliarSystem : EntitySystem
{
[Dependency] private readonly SharedPsionicAbilitiesSystem _psionics = default!;
[Dependency] private readonly NpcFactionSystem _factions = default!;
[Dependency] private readonly NPCSystem _npc = default!;
[Dependency] private readonly HTNSystem _htn = default!;
[Dependency] private readonly PopupSystem _popup = default!;

public override void Initialize()
{
base.Initialize();

SubscribeLocalEvent<PsionicComponent, SummonPsionicFamiliarActionEvent>(OnSummon);
SubscribeLocalEvent<PsionicFamiliarComponent, ComponentShutdown>(OnFamiliarShutdown);
SubscribeLocalEvent<PsionicFamiliarComponent, AttackAttemptEvent>(OnFamiliarAttack);
SubscribeLocalEvent<PsionicFamiliarComponent, MobStateChangedEvent>(OnFamiliarDeath);
}

private void OnSummon(EntityUid uid, PsionicComponent psionicComponent, SummonPsionicFamiliarActionEvent args)
{
if (psionicComponent.Familiars.Count >= psionicComponent.FamiliarLimit
|| !_psionics.OnAttemptPowerUse(args.Performer, args.PowerName, args.ManaCost, args.CheckInsulation)
|| args.Handled || args.FamiliarProto is null)
return;

args.Handled = true;
var familiar = Spawn(args.FamiliarProto, Transform(uid).Coordinates);
EnsureComp<PsionicFamiliarComponent>(familiar, out var familiarComponent);
familiarComponent.Master = uid;
psionicComponent.Familiars.Add(familiar);
Dirty(familiar, familiarComponent);
Dirty(uid, psionicComponent);

InheritFactions(uid, familiar, familiarComponent);
HandleBlackboards(uid, familiar, args);
DoGlimmerEffects(uid, psionicComponent, args);
}

private void InheritFactions(EntityUid uid, EntityUid familiar, PsionicFamiliarComponent familiarComponent)
{
if (!familiarComponent.InheritMasterFactions
|| !TryComp<NpcFactionMemberComponent>(uid, out var masterFactions)
|| masterFactions.Factions.Count <= 0)
return;

EnsureComp<NpcFactionMemberComponent>(familiar, out var familiarFactions);
foreach (var faction in masterFactions.Factions)
{
if (familiarFactions.Factions.Contains(faction))
continue;

_factions.AddFaction(familiar, faction, true);
}
}

private void HandleBlackboards(EntityUid master, EntityUid familiar, SummonPsionicFamiliarActionEvent args)
{
if (!args.FollowMaster
|| !TryComp<HTNComponent>(familiar, out var htnComponent))
return;

_npc.SetBlackboard(familiar, NPCBlackboard.FollowTarget, new EntityCoordinates(master, Vector2.Zero), htnComponent);
_htn.Replan(htnComponent);
}

private void DoGlimmerEffects(EntityUid uid, PsionicComponent component, SummonPsionicFamiliarActionEvent args)
{
if (!args.DoGlimmerEffects
|| args.MinGlimmer == 0 && args.MaxGlimmer == 0)
return;

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);
}

private void OnFamiliarShutdown(EntityUid uid, PsionicFamiliarComponent component, ComponentShutdown args)
{
if (!Exists(component.Master)
|| !TryComp<PsionicComponent>(component.Master, out var psionicComponent)
|| !psionicComponent.Familiars.Contains(uid))
return;

psionicComponent.Familiars.Remove(uid);
}

private void OnFamiliarAttack(EntityUid uid, PsionicFamiliarComponent component, AttackAttemptEvent args)
{
if (component.CanAttackMaster || args.Target is null
|| args.Target != component.Master)
return;

args.Cancel();
if (!Loc.TryGetString(component.AttackMasterText, out var attackFailMessage))
return;

_popup.PopupEntity(attackFailMessage, uid, uid, component.AttackPopupType);
}

private void OnFamiliarDeath(EntityUid uid, PsionicFamiliarComponent component, MobStateChangedEvent args)
{
if (!component.DespawnOnFamiliarDeath
|| args.NewMobState != MobState.Dead)
return;

DespawnFamiliar(uid, component);
}

public void DespawnFamiliar(EntityUid uid)
{
if (!TryComp<PsionicFamiliarComponent>(uid, out var familiarComponent))
return;

DespawnFamiliar(uid, familiarComponent);
}

public void DespawnFamiliar(EntityUid uid, PsionicFamiliarComponent component)
{
var popupText = Loc.GetString(component.DespawnText, ("entity", MetaData(uid).EntityName));
_popup.PopupEntity(popupText, uid, component.DespawnPopopType);
QueueDel(uid);
}
}
6 changes: 6 additions & 0 deletions Content.Server/NPC/Components/NPCRetaliationComponent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,10 @@ public sealed partial class NPCRetaliationComponent : Component
/// todo: this needs to support timeoffsetserializer at some point
[DataField("attackMemories")]
public Dictionary<EntityUid, TimeSpan> AttackMemories = new();

/// <summary>
/// Whether this NPC will retaliate against a "Friendly" NPC.
/// </summary>
[DataField]
public bool RetaliateFriendlies;
}
1 change: 0 additions & 1 deletion Content.Server/NPC/Components/NpcFactionMemberComponent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
namespace Content.Server.NPC.Components
{
[RegisterComponent]
[Access(typeof(NpcFactionSystem))]
public sealed partial class NpcFactionMemberComponent : Component
{
/// <summary>
Expand Down
3 changes: 2 additions & 1 deletion Content.Server/NPC/Systems/NPCRetaliationSystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ public bool TryRetaliate(EntityUid uid, EntityUid target, NPCRetaliationComponen
if (!HasComp<MobStateComponent>(target))
return false;

if (_npcFaction.IsEntityFriendly(uid, target))
if (!component.RetaliateFriendlies
&& _npcFaction.IsEntityFriendly(uid, target))
return false;

_npcFaction.AggroEntity(uid, target);
Expand Down
64 changes: 63 additions & 1 deletion Content.Server/Psionics/PsionicsSystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
using Robust.Server.Player;
using Content.Server.Chat.Managers;
using Robust.Shared.Prototypes;
using Content.Shared.Psionics;
using Content.Shared.Mobs;
using Content.Shared.Damage;
using Content.Shared.Interaction.Events;

namespace Content.Server.Psionics;

Expand All @@ -35,6 +37,8 @@ public sealed class PsionicsSystem : EntitySystem
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IChatManager _chatManager = default!;
[Dependency] private readonly IPrototypeManager _protoMan = default!;
[Dependency] private readonly PsionicFamiliarSystem _psionicFamiliar = default!;
[Dependency] private readonly NPCRetaliationSystem _retaliationSystem = default!;

private const string BaselineAmplification = "Baseline Amplification";
private const string BaselineDampening = "Baseline Dampening";
Expand Down Expand Up @@ -64,6 +68,9 @@ public override void Initialize()
SubscribeLocalEvent<PsionicComponent, MapInitEvent>(OnStartup);
SubscribeLocalEvent<AntiPsionicWeaponComponent, MeleeHitEvent>(OnMeleeHit);
SubscribeLocalEvent<AntiPsionicWeaponComponent, TakeStaminaDamageEvent>(OnStamHit);
SubscribeLocalEvent<PsionicComponent, MobStateChangedEvent>(OnMobstateChanged);
SubscribeLocalEvent<PsionicComponent, DamageChangedEvent>(OnDamageChanged);
SubscribeLocalEvent<PsionicComponent, AttackAttemptEvent>(OnAttackAttempt);

SubscribeLocalEvent<PsionicComponent, ComponentStartup>(OnInit);
SubscribeLocalEvent<PsionicComponent, ComponentRemove>(OnRemove);
Expand Down Expand Up @@ -250,4 +257,59 @@ public void RerollPsionics(EntityUid uid, PsionicComponent? psionic = null, floa
RollPsionics(uid, psionic, true, bonusMuliplier);
psionic.CanReroll = false;
}

private void OnMobstateChanged(EntityUid uid, PsionicComponent component, MobStateChangedEvent args)
{
if (component.Familiars.Count <= 0
|| args.NewMobState != MobState.Dead)
return;

foreach (var familiar in component.Familiars)
{
if (!TryComp<PsionicFamiliarComponent>(familiar, out var familiarComponent)
|| !familiarComponent.DespawnOnMasterDeath)
continue;

_psionicFamiliar.DespawnFamiliar(familiar, familiarComponent);
}
}

/// <summary>
/// When a caster with active summons is attacked, aggro their familiars to the attacker.
/// </summary>
private void OnDamageChanged(EntityUid uid, PsionicComponent component, DamageChangedEvent args)
{
if (component.Familiars.Count <= 0
|| !args.DamageIncreased
|| args.Origin is not { } origin
|| origin == uid)
return;

SetFamiliarTarget(origin, component);
}

/// <summary>
/// When a caster with active summons attempts to attack something, aggro their familiars to the target.
/// </summary>
private void OnAttackAttempt(EntityUid uid, PsionicComponent component, AttackAttemptEvent args)
{
if (component.Familiars.Count <= 0
|| args.Target == uid
|| args.Target is not { } target
|| component.Familiars.Contains(target))
return;

SetFamiliarTarget(target, component);
}

private void SetFamiliarTarget(EntityUid target, PsionicComponent component)
{
foreach (var familiar in component.Familiars)
{
if (!TryComp<NPCRetaliationComponent>(familiar, out var retaliationComponent))
continue;

_retaliationSystem.TryRetaliate(familiar, target, retaliationComponent);
}
}
}
54 changes: 54 additions & 0 deletions Content.Shared/Actions/Events/SummonPsionicFamiliarActionEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using Robust.Shared.Prototypes;

namespace Content.Shared.Actions.Events;

public sealed partial class SummonPsionicFamiliarActionEvent : InstantActionEvent
{
/// <summary>
/// The entity to be spawned by this power.
/// </summary>
[DataField]
public EntProtoId? FamiliarProto;

/// <summary>
/// The name of this power, used for logging purposes.
/// </summary>
[DataField]
public string PowerName;

/// <summary>
/// How much Mana this power should cost, if any.
/// </summary>
[DataField]
public float ManaCost;

/// <summary>
/// Whether this power checks if the wearer is psionically insulated.
/// </summary>
[DataField]
public bool CheckInsulation;

/// <summary>
/// Whether this power generates glimmer when used.
/// </summary>
[DataField]
public bool DoGlimmerEffects;

/// <summary>
/// Whether the summoned entity will follow the one who summoned it.
/// </summary>
[DataField]
public bool FollowMaster;

/// <summary>
/// The minimum amount of glimmer generated by this power.
/// </summary>
[DataField]
public int MinGlimmer;

/// <summary>
/// The maximum amount of glimmer generated by this power.
/// </summary>
[DataField]
public int MaxGlimmer;
}
12 changes: 12 additions & 0 deletions Content.Shared/Psionics/PsionicComponent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -213,5 +213,17 @@ private set

/// Popup to play if there no Mana left for a power to execute.
public string NoMana = "no-mana";

/// <summary>
/// The list of Familiars currently bound to this Psion.
/// </summary>
[DataField]
public List<EntityUid> Familiars = new();

/// <summary>
/// The maximum number of Familiars a Psion may bind.
/// </summary>
[DataField]
public int FamiliarLimit = 1;
}
}
Loading

0 comments on commit cfcc4cb

Please sign in to comment.