Prediction + Refactor: BibleSytem (#44800)

* Prediction

* Update

* Update

* Fix

* Rename file

* Fix

* Update

* Fix

* cleanup

* EntityUid? Source

* Revert "EntityUid? Source"

This reverts commit c169e1f030b1fced6951b8026dda8f4e01571424.

* ftl and fix PVS

* Update

* review

* Update

---------

Co-authored-by: Pok245 <113675512+Pok27@users.noreply.github.com>
This commit is contained in:
Winkarst-cpu 2026-07-22 21:03:36 +00:00 • committed by GitHub
parent 54986e0347
commit c58331c4f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 575 additions and 438 deletions

View file

@ -1,256 +0,0 @@
using Content.Server.Bible.Components;
using Content.Server.Ghost.Roles.Events;
using Content.Server.Popups;
using Content.Shared.ActionBlocker;
using Content.Shared.Actions;
using Content.Shared.Bible;
using Content.Shared.Damage.Systems;
using Content.Shared.Ghost.Roles.Components;
using Content.Shared.IdentityManagement;
using Content.Shared.Interaction;
using Content.Shared.Inventory;
using Content.Shared.Mobs;
using Content.Shared.Mobs.Systems;
using Content.Shared.Popups;
using Content.Shared.Timing;
using Content.Shared.Verbs;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Map;
using Robust.Shared.Player;
using Robust.Shared.Random;
namespace Content.Server.Bible
{
public sealed partial class BibleSystem : EntitySystem
{
[Dependency] private IRobustRandom _random = default!;
[Dependency] private ActionBlockerSystem _blocker = default!;
[Dependency] private DamageableSystem _damageableSystem = default!;
[Dependency] private InventorySystem _invSystem = default!;
[Dependency] private MobStateSystem _mobStateSystem = default!;
[Dependency] private PopupSystem _popupSystem = default!;
[Dependency] private SharedActionsSystem _actionsSystem = default!;
[Dependency] private SharedAudioSystem _audio = default!;
[Dependency] private UseDelaySystem _delay = default!;
[Dependency] private SharedTransformSystem _transform = default!;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<BibleComponent, AfterInteractEvent>(OnAfterInteract);
SubscribeLocalEvent<SummonableComponent, GetVerbsEvent<AlternativeVerb>>(AddSummonVerb);
SubscribeLocalEvent<SummonableComponent, GetItemActionsEvent>(GetSummonAction);
SubscribeLocalEvent<SummonableComponent, SummonActionEvent>(OnSummon);
SubscribeLocalEvent<FamiliarComponent, MobStateChangedEvent>(OnFamiliarDeath);
SubscribeLocalEvent<FamiliarComponent, GhostRoleSpawnerUsedEvent>(OnSpawned);
}
private readonly Queue<EntityUid> _addQueue = new();
private readonly Queue<EntityUid> _remQueue = new();
/// <summary>
/// This handles familiar respawning.
/// </summary>
public override void Update(float frameTime)
{
base.Update(frameTime);
foreach (var entity in _addQueue)
{
EnsureComp<SummonableRespawningComponent>(entity);
}
_addQueue.Clear();
foreach (var entity in _remQueue)
{
RemComp<SummonableRespawningComponent>(entity);
}
_remQueue.Clear();
var query = EntityQueryEnumerator<SummonableRespawningComponent, SummonableComponent>();
while (query.MoveNext(out var uid, out var _, out var summonableComp))
{
summonableComp.Accumulator += frameTime;
if (summonableComp.Accumulator < summonableComp.RespawnTime)
{
continue;
}
// Clean up the old body
if (summonableComp.Summon != null)
{
Del(summonableComp.Summon.Value);
summonableComp.Summon = null;
}
summonableComp.AlreadySummoned = false;
_popupSystem.PopupEntity(Loc.GetString("bible-summon-respawn-ready", ("book", uid)), uid, PopupType.Medium);
_audio.PlayPvs(summonableComp.SummonSound, uid);
// Clean up the accumulator and respawn tracking component
summonableComp.Accumulator = 0;
_remQueue.Enqueue(uid);
}
}
private void OnAfterInteract(EntityUid uid, BibleComponent component, AfterInteractEvent args)
{
if (!args.CanReach)
return;
if (!TryComp(uid, out UseDelayComponent? useDelay) || _delay.IsDelayed((uid, useDelay)))
return;
if (args.Target == null || args.Target == args.User || !_mobStateSystem.IsAlive(args.Target.Value))
{
return;
}
if (!HasComp<BibleUserComponent>(args.User))
{
_popupSystem.PopupEntity(Loc.GetString("bible-sizzle"), args.User, args.User);
_audio.PlayPvs(component.SizzleSoundPath, args.User);
_damageableSystem.TryChangeDamage(args.User, component.DamageOnUntrainedUse, true, origin: uid);
_delay.TryResetDelay((uid, useDelay));
return;
}
var userEnt = Identity.Entity(args.User, EntityManager);
var targetEnt = Identity.Entity(args.Target.Value, EntityManager);
// This only has a chance to fail if the target is not wearing anything on their head and is not a familiar.
if (!_invSystem.TryGetSlotEntity(args.Target.Value, "head", out _) && !HasComp<FamiliarComponent>(args.Target.Value))
{
if (_random.Prob(component.FailChance))
{
var othersFailMessage = Loc.GetString(component.LocPrefix + "-heal-fail-others", ("user", userEnt), ("target", targetEnt), ("bible", uid));
_popupSystem.PopupEntity(othersFailMessage, args.User, Filter.PvsExcept(args.User), true, PopupType.SmallCaution);
var selfFailMessage = Loc.GetString(component.LocPrefix + "-heal-fail-self", ("target", targetEnt), ("bible", uid));
_popupSystem.PopupEntity(selfFailMessage, args.User, args.User, PopupType.MediumCaution);
_audio.PlayPvs(component.BibleHitSound, args.User);
_damageableSystem.TryChangeDamage(args.Target.Value, component.DamageOnFail, true, origin: uid);
_delay.TryResetDelay((uid, useDelay));
return;
}
}
string othersMessage;
string selfMessage;
if (_damageableSystem.TryChangeDamage(args.Target.Value, component.Damage, true, origin: uid))
{
othersMessage = Loc.GetString(component.LocPrefix + "-heal-success-others", ("user", userEnt), ("target", targetEnt), ("bible", uid));
selfMessage = Loc.GetString(component.LocPrefix + "-heal-success-self", ("target", targetEnt), ("bible", uid));
_audio.PlayPvs(component.HealSoundPath, args.User);
_delay.TryResetDelay((uid, useDelay));
if (component.HealingLightEffect.HasValue)
Spawn(component.HealingLightEffect.Value, new EntityCoordinates(args.Target.Value, default));
}
else
{
othersMessage = Loc.GetString(component.LocPrefix + "-heal-success-none-others", ("user", userEnt), ("target", targetEnt), ("bible", uid));
selfMessage = Loc.GetString(component.LocPrefix + "-heal-success-none-self", ("target", targetEnt), ("bible", uid));
}
_popupSystem.PopupEntity(othersMessage, args.User, Filter.PvsExcept(args.User), true, PopupType.Medium);
_popupSystem.PopupEntity(selfMessage, args.User, args.User, PopupType.Large);
}
private void AddSummonVerb(EntityUid uid, SummonableComponent component, GetVerbsEvent<AlternativeVerb> args)
{
if (!args.CanInteract || !args.CanAccess || component.AlreadySummoned || component.SpecialItemPrototype == null)
return;
if (component.RequiresBibleUser && !HasComp<BibleUserComponent>(args.User))
return;
AlternativeVerb verb = new()
{
Act = () =>
{
if (!TryComp(args.User, out TransformComponent? userXform))
return;
AttemptSummon((uid, component), args.User, userXform);
},
Text = Loc.GetString("bible-summon-verb"),
Priority = 2
};
args.Verbs.Add(verb);
}
private void GetSummonAction(EntityUid uid, SummonableComponent component, GetItemActionsEvent args)
{
if (component.AlreadySummoned)
return;
args.AddAction(ref component.SummonActionEntity, component.SummonAction);
}
private void OnSummon(Entity<SummonableComponent> ent, ref SummonActionEvent args)
{
AttemptSummon(ent, args.Performer, Transform(args.Performer));
}
/// <summary>
/// Starts up the respawn stuff when
/// the chaplain's familiar dies.
/// </summary>
private void OnFamiliarDeath(EntityUid uid, FamiliarComponent component, MobStateChangedEvent args)
{
if (args.NewMobState != MobState.Dead || component.Source == null)
return;
var source = component.Source;
if (source != null && HasComp<SummonableComponent>(source))
{
_addQueue.Enqueue(source.Value);
}
}
/// <summary>
/// When the familiar spawns, set its source to the bible.
/// </summary>
private void OnSpawned(EntityUid uid, FamiliarComponent component, GhostRoleSpawnerUsedEvent args)
{
var parent = Transform(args.Spawner).ParentUid;
if (!TryComp<SummonableComponent>(parent, out var summonable))
return;
component.Source = parent;
summonable.Summon = uid;
}
private void AttemptSummon(Entity<SummonableComponent> ent, EntityUid user, TransformComponent? position)
{
var (uid, component) = ent;
if (component.AlreadySummoned || component.SpecialItemPrototype == null)
return;
if (component.RequiresBibleUser && !HasComp<BibleUserComponent>(user))
return;
if (!Resolve(user, ref position))
return;
if (component.Deleted || Deleted(uid))
return;
if (!_blocker.CanInteract(user, uid))
return;
// Make this familiar the component's summon
var familiar = Spawn(component.SpecialItemPrototype, position.Coordinates);
component.Summon = familiar;
// If this is going to use a ghost role mob spawner, attach it to the bible.
if (HasComp<GhostRoleMobSpawnerComponent>(familiar))
{
_popupSystem.PopupEntity(Loc.GetString("bible-summon-requested"), user, user, PopupType.Medium);
_transform.SetParent(familiar, uid);
}
component.AlreadySummoned = true;
_actionsSystem.RemoveAction(user, component.SummonActionEntity);
}
}
}

View file

@ -1,63 +0,0 @@
using Content.Shared.Damage;
using Robust.Shared.Audio;
using Robust.Shared.Prototypes;
namespace Content.Server.Bible.Components
{
[RegisterComponent]
public sealed partial class BibleComponent : Component
{
/// <summary>
/// Default sound when bible hits somebody.
/// </summary>
private static readonly ProtoId<SoundCollectionPrototype> DefaultBibleHit = new("BibleHit");
/// <summary>
/// Sound to play when bible hits somebody.
/// </summary>
[DataField]
public SoundSpecifier BibleHitSound = new SoundCollectionSpecifier(DefaultBibleHit, AudioParams.Default.WithVolume(-4f));
/// <summary>
/// Damage that will be healed on a success
/// </summary>
[DataField("damage", required: true)]
[ViewVariables(VVAccess.ReadWrite)]
public DamageSpecifier Damage = default!;
/// <summary>
/// Damage that will be dealt on a failure
/// </summary>
[DataField("damageOnFail", required: true)]
[ViewVariables(VVAccess.ReadWrite)]
public DamageSpecifier DamageOnFail = default!;
/// <summary>
/// Damage that will be dealt when a non-chaplain attempts to heal
/// </summary>
[DataField("damageOnUntrainedUse", required: true)]
[ViewVariables(VVAccess.ReadWrite)]
public DamageSpecifier DamageOnUntrainedUse = default!;
/// <summary>
/// Chance the bible will fail to heal someone with no helmet
/// </summary>
[DataField("failChance")]
[ViewVariables(VVAccess.ReadWrite)]
public float FailChance = 0.34f;
[DataField("sizzleSound")]
public SoundSpecifier SizzleSoundPath = new SoundPathSpecifier("/Audio/Effects/lightburn.ogg");
[DataField("healSound")]
public SoundSpecifier HealSoundPath = new SoundPathSpecifier("/Audio/Effects/holy.ogg");
[DataField("locPrefix")]
public string LocPrefix = "bible";
/// <summary>
/// A short light effect to display when successfully healing someone
/// </summary>
[DataField]
public EntProtoId? HealingLightEffect = "HolyLightEffect";
}
}

View file

@ -1,5 +0,0 @@
namespace Content.Server.Bible.Components
{
[RegisterComponent]
public sealed partial class BibleUserComponent : Component {}
}

View file

@ -1,17 +0,0 @@
namespace Content.Server.Bible.Components
{
/// <summary>
/// This component is for the chaplain's familiars, and mostly
/// used to track their current state and to give a component to check for
/// if any special behavior is needed.
/// </summary>
[RegisterComponent]
public sealed partial class FamiliarComponent : Component
{
/// <summary>
/// The entity this familiar was summoned from.
/// </summary>
[ViewVariables]
public EntityUid? Source = null;
}
}

View file

@ -1,52 +0,0 @@
using Robust.Shared.Audio;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
namespace Content.Server.Bible.Components
{
/// <summary>
/// This lets you summon a mob or item with an alternative verb on the item
/// </summary>
[RegisterComponent]
public sealed partial class SummonableComponent : Component
{
/// <summary>
/// Default sound to play when entity is summoned.
/// </summary>
private static readonly ProtoId<SoundCollectionPrototype> DefaultSummonSound = new("Summon");
/// <summary>
/// Sound to play when entity is summoned.
/// </summary>
[DataField]
public SoundSpecifier SummonSound = new SoundCollectionSpecifier(DefaultSummonSound, AudioParams.Default.WithVolume(-4f));
/// <summary>
/// Used for a special item only the Chaplain can summon. Usually a mob, but supports regular items too.
/// </summary>
[DataField("specialItem", customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))]
public string? SpecialItemPrototype = null;
public bool AlreadySummoned = false;
[DataField("requiresBibleUser")]
public bool RequiresBibleUser = true;
/// <summary>
/// The specific creature this summoned, if the SpecialItemPrototype has a mobstate.
/// </summary>
[ViewVariables]
public EntityUid? Summon = null;
[DataField("summonAction", customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))]
public string SummonAction = "ActionBibleSummon";
[DataField("summonActionEntity")]
public EntityUid? SummonActionEntity;
/// Used for respawning
[DataField("accumulator")]
public float Accumulator = 0f;
[DataField("respawnTime")]
public float RespawnTime = 180f;
}
}

View file

@ -1,9 +0,0 @@
namespace Content.Server.Bible.Components
{
/// <summary>
/// EntityQuery Tracking Component for Summonables that are counting up a respawn.
/// </summary>
[RegisterComponent]
public sealed partial class SummonableRespawningComponent : Component
{}
}

View file

@ -1,25 +0,0 @@
namespace Content.Server.Ghost.Roles.Events
{
/// <summary>
/// Raised on a spawned entity after they use a ghost role mob spawner.
/// </summary>
public sealed class GhostRoleSpawnerUsedEvent : EntityEventArgs
{
/// <summary>
/// The entity that spawned this.
/// </summary>
public EntityUid Spawner;
/// <summary>
/// The entity spawned.
/// </summary>
public EntityUid Spawned;
public GhostRoleSpawnerUsedEvent(EntityUid spawner, EntityUid spawned)
{
Spawner = spawner;
Spawned = spawned;
}
}
}

View file

@ -4,7 +4,6 @@ using Content.Server.Administration.Managers;
using Content.Server.EUI;
using Content.Server.GameTicking.Events;
using Content.Server.Ghost.Roles.Components;
using Content.Server.Ghost.Roles.Events;
using Content.Shared.Ghost.Roles.Raffles;
using Content.Server.Ghost.Roles.UI;
using Content.Shared.Administration;
@ -784,7 +783,7 @@ public sealed partial class GhostRoleSystem : EntitySystem
var mob = Spawn(component.Prototype, spawnCoordinates.Value);
var spawnedEvent = new GhostRoleSpawnerUsedEvent(uid, mob);
RaiseLocalEvent(mob, spawnedEvent);
RaiseLocalEvent(mob, ref spawnedEvent);
if (ghostRole.MakeSentient)
_mindSystem.MakeSentient(mob, ghostRole.AllowMovement, ghostRole.AllowSpeech);

View file

@ -1,6 +1,5 @@
using Content.Server.Administration;
using Content.Server.Administration.Logs;
using Content.Server.Bible.Components;
using Content.Server.Chat.Managers;
using Content.Server.Popups;
using Content.Shared.Database;
@ -10,6 +9,7 @@ using Content.Shared.Prayer;
using Content.Shared.Verbs;
using Robust.Server.GameObjects;
using Robust.Shared.Player;
using Content.Shared.Bible.Components;
namespace Content.Server.Prayer;
/// <summary>
@ -48,7 +48,7 @@ public sealed partial class PrayerSystem : EntitySystem
Icon = comp.VerbImage,
Act = () =>
{
if (comp.BibleUserOnly && !TryComp<BibleUserComponent>(args.User, out var bibleUser))
if (comp.BibleUserOnly && !HasComp<BibleUserComponent>(args.User))
{
_popupSystem.PopupEntity(Loc.GetString("prayer-popup-notify-pray-locked"), uid, actor.PlayerSession, PopupType.Large);
return;

View file

@ -0,0 +1,291 @@
using Content.Shared.ActionBlocker;
using Content.Shared.Actions;
using Content.Shared.Bible.Components;
using Content.Shared.Damage.Systems;
using Content.Shared.Ghost;
using Content.Shared.Ghost.Roles.Components;
using Content.Shared.IdentityManagement;
using Content.Shared.Interaction;
using Content.Shared.Inventory;
using Content.Shared.Mobs;
using Content.Shared.Mobs.Systems;
using Content.Shared.Popups;
using Content.Shared.Random.Helpers;
using Content.Shared.Timing;
using Content.Shared.Verbs;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Containers;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Random;
using Robust.Shared.Timing;
namespace Content.Shared.Bible;
/// <summary>
/// Handles bible healing on hit, and summoning/respawning of familiars via <see cref="SummonableComponent"/>.
/// </summary>
public sealed partial class BibleSystem : EntitySystem
{
[Dependency] private ActionBlockerSystem _blocker = default!;
[Dependency] private DamageableSystem _damageable = default!;
[Dependency] private IGameTiming _timing = default!;
[Dependency] private INetManager _net = default!;
[Dependency] private InventorySystem _inventory = default!;
[Dependency] private MobStateSystem _mobState = default!;
[Dependency] private SharedActionsSystem _actions = default!;
[Dependency] private SharedAudioSystem _audio = default!;
[Dependency] private SharedContainerSystem _container = default!;
[Dependency] private SharedPopupSystem _popup = default!;
[Dependency] private SharedTransformSystem _transform = default!;
[Dependency] private UseDelaySystem _delay = default!;
/// <summary>
/// This handles familiar respawning.
/// </summary>
public override void Update(float frameTime)
{
base.Update(frameTime);
var query = EntityQueryEnumerator<SummonableRespawningComponent, SummonableComponent>();
while (query.MoveNext(out var uid, out var respawningComponent, out var summonableComp))
{
if (summonableComp.RespawnTime > _timing.CurTime)
continue;
// Delete old summoned entity.
if (Exists(summonableComp.SummonedEntity))
PredictedDel(summonableComp.SummonedEntity);
summonableComp.CanSummon = true;
Dirty(uid, summonableComp);
// Re-add summon action.
if (summonableComp.SummonActionEntity is { } action
&& _container.TryGetContainingContainer(uid, out var container))
{
_actions.GrantContainedAction(container.Owner, uid, action);
}
if (_net.IsServer)
{
_popup.PopupEntity(Loc.GetString(summonableComp.SummonRespawnReadyText, ("book", uid)), uid, PopupType.Medium);
_audio.PlayPvs(summonableComp.SummonSound, uid);
}
RemCompDeferred(uid, respawningComponent);
}
}
[SubscribeLocalEvent]
private void OnAfterInteract(Entity<BibleComponent> ent, ref AfterInteractEvent args)
{
if (!args.CanReach)
return;
if (!TryComp<UseDelayComponent>(ent, out var useDelay) || _delay.IsDelayed((ent, useDelay)))
return;
if (args.Target == null || args.Target == args.User || !_mobState.IsAlive(args.Target.Value))
return;
var userEnt = Identity.Entity(args.User, EntityManager);
var targetEnt = Identity.Entity(args.Target.Value, EntityManager);
// Sizzle user if they are not a Bible user.
if (!HasComp<BibleUserComponent>(args.User))
{
_popup.PopupEntity(Loc.GetString(ent.Comp.SizzleText, ("user", userEnt), ("target", targetEnt), ("bible", ent)), args.User, args.User);
_audio.PlayPredicted(ent.Comp.SizzleSound, ent, args.User);
_damageable.TryChangeDamage(args.User, ent.Comp.DamageOnUntrainedUse, true, origin: ent);
_delay.TryResetDelay((ent, useDelay));
return;
}
// This only has a chance to fail if the target is not wearing anything on their head and is not a familiar.
if (!_inventory.TryGetSlotEntity(args.Target.Value, "head", out _) && !HasComp<FamiliarComponent>(args.Target.Value))
{
var rand = SharedRandomExtensions.PredictedRandom(_timing, GetNetEntity(ent));
if (rand.Prob(ent.Comp.FailChance))
{
var othersFailMessage = Loc.GetString(ent.Comp.HealFailOthersText, ("user", userEnt), ("target", targetEnt), ("bible", ent));
_popup.PopupEntity(othersFailMessage, args.User, Filter.PvsExcept(args.User), true, PopupType.SmallCaution);
var selfFailMessage = Loc.GetString(ent.Comp.HealFailSelfText, ("user", userEnt), ("target", targetEnt), ("bible", ent));
_popup.PopupEntity(selfFailMessage, args.User, args.User, PopupType.MediumCaution);
_audio.PlayPredicted(ent.Comp.BibleHitSound, ent, args.User);
_damageable.TryChangeDamage(args.Target.Value, ent.Comp.DamageOnFail, true, origin: ent);
_delay.TryResetDelay((ent, useDelay));
return;
}
}
string othersMessage;
string selfMessage;
if (_damageable.TryChangeDamage(args.Target.Value, ent.Comp.Damage, true, origin: ent))
{
othersMessage = Loc.GetString(ent.Comp.HealSuccessOthersText, ("user", userEnt), ("target", targetEnt), ("bible", ent));
selfMessage = Loc.GetString(ent.Comp.HealSuccessSelfText, ("user", userEnt), ("target", targetEnt), ("bible", ent));
_audio.PlayPredicted(ent.Comp.HealSound, ent, args.User);
_delay.TryResetDelay((ent, useDelay));
if (ent.Comp.HealingLightEffect.HasValue)
PredictedSpawnAtPosition(ent.Comp.HealingLightEffect.Value, Transform(args.Target.Value).Coordinates);
}
else
{
othersMessage = Loc.GetString(ent.Comp.HealSuccessNoneOthersText, ("user", userEnt), ("target", targetEnt), ("bible", ent));
selfMessage = Loc.GetString(ent.Comp.HealSuccessNoneSelfText, ("user", userEnt), ("target", targetEnt), ("bible", ent));
}
_popup.PopupEntity(othersMessage, args.User, Filter.PvsExcept(args.User), true, PopupType.Medium);
_popup.PopupEntity(selfMessage, args.User, args.User, PopupType.Large);
}
[SubscribeLocalEvent]
private void AddSummonVerb(Entity<SummonableComponent> ent, ref GetVerbsEvent<AlternativeVerb> args)
{
if (!args.CanInteract || !args.CanAccess || !CanSummon(ent, args.User))
return;
var user = args.User;
AlternativeVerb verb = new()
{
Act = () =>
{
AttemptSummon(ent, user);
},
Text = Loc.GetString(ent.Comp.SummonVerbText),
Priority = 2
};
args.Verbs.Add(verb);
}
[SubscribeLocalEvent]
private void GetSummonAction(Entity<SummonableComponent> ent, ref GetItemActionsEvent args)
{
if (!CanSummon(ent, args.User))
return;
args.AddAction(ref ent.Comp.SummonActionEntity, ent.Comp.SummonActionPrototype);
}
[SubscribeLocalEvent]
private void OnSummon(Entity<SummonableComponent> ent, ref SummonActionEvent args)
{
AttemptSummon(ent, args.Performer);
}
[SubscribeLocalEvent]
private void OnFamiliarDeath(Entity<FamiliarComponent> ent, ref MobStateChangedEvent args)
{
if (args.NewMobState != MobState.Dead || ent.Comp.Source == null)
return;
StartRespawnTimer(ent.Comp.Source.Value);
}
[SubscribeLocalEvent]
private void OnFamiliarRemoved(Entity<FamiliarComponent> ent, ref ComponentRemove args)
{
if (!TryComp<SummonableComponent>(ent.Comp.Source, out var summonable))
return;
// If the entity is no longer a familiar, then there’s no need to keep track of it anymore
summonable.SummonedEntity = null;
Dirty(ent.Comp.Source.Value, summonable);
StartRespawnTimer((ent.Comp.Source.Value, summonable));
}
[SubscribeLocalEvent]
private void OnSummonableRemoved(Entity<SummonableComponent> ent, ref ComponentRemove args)
{
if (!TryComp<FamiliarComponent>(ent.Comp.SummonedEntity, out var familiar))
return;
familiar.Source = null;
Dirty(ent.Comp.SummonedEntity.Value, familiar);
}
/// <summary>
/// When the familiar spawns, set its source to the bible.
/// </summary>
[SubscribeLocalEvent]
private void OnSpawned(Entity<FamiliarComponent> ent, ref GhostRoleSpawnerUsedEvent args)
{
var parent = Transform(args.Spawner).ParentUid;
if (!TryComp<SummonableComponent>(parent, out var summonable))
return;
ent.Comp.Source = parent;
Dirty(ent);
summonable.SummonedEntity = ent;
Dirty(parent, summonable);
}
/// <summary>
/// Starts up the respawn timer for the chaplain's familiar.
/// </summary>
private void StartRespawnTimer(Entity<SummonableComponent?> ent)
{
if (!Resolve(ent, ref ent.Comp, false))
return;
EnsureComp<SummonableRespawningComponent>(ent);
ent.Comp.RespawnTime = _timing.CurTime + ent.Comp.RespawnCooldown;
Dirty(ent);
}
/// <summary>
/// Checks whether the summonable entity can be summoned by the given user.
/// </summary>
private bool CanSummon(Entity<SummonableComponent> ent, EntityUid user)
{
return ent.Comp.CanSummon
&& ent.Comp.SummonEntityPrototype.HasValue
&& (!ent.Comp.RequiresBibleUser || HasComp<BibleUserComponent>(user));
}
/// <summary>
/// Attempts to summon a new familiar.
/// </summary>
private void AttemptSummon(Entity<SummonableComponent> ent, EntityUid user)
{
if (!CanSummon(ent, user))
return;
if (!_blocker.CanInteract(user, ent))
return;
// Make this familiar the component's summon
var familiar = PredictedSpawnAtPosition(ent.Comp.SummonEntityPrototype, Transform(user).Coordinates);
ent.Comp.SummonedEntity = familiar;
// If this is going to use a ghost role mob spawner, attach it to the bible.
if (HasComp<GhostRoleMobSpawnerComponent>(familiar))
{
_popup.PopupEntity(Loc.GetString(ent.Comp.SummonRequestedText), user, user, PopupType.Medium);
_transform.SetParent(familiar, ent);
}
else
{
EnsureComp<FamiliarComponent>(familiar, out var familiarComponent);
familiarComponent.Source = ent;
Dirty(familiar, familiarComponent);
}
_actions.RemoveAction(user, ent.Comp.SummonActionEntity);
ent.Comp.CanSummon = false;
Dirty(ent);
}
}

View file

@ -0,0 +1,119 @@
using Content.Shared.Damage;
using Robust.Shared.Audio;
using Robust.Shared.GameStates;
using Robust.Shared.Prototypes;
namespace Content.Shared.Bible.Components;
/// <summary>
/// Marks an entity as Bible that heals somebody on interaction.
/// </summary>
[RegisterComponent, NetworkedComponent]
[Access(typeof(BibleSystem))]
public sealed partial class BibleComponent : Component
{
/// <summary>
/// Default sound when Bible hits somebody.
/// </summary>
private static readonly ProtoId<SoundCollectionPrototype> DefaultBibleHitSound = new("BibleHit");
/// <summary>
/// Default sound when Bible fails to heal somebody.
/// </summary>
private static readonly ProtoId<SoundCollectionPrototype> DefaultBibleSizzleSound = new("BibleSizzle");
/// <summary>
/// Default sound when Bible heals somebody.
/// </summary>
private static readonly ProtoId<SoundCollectionPrototype> DefaultBibleHealSound = new("BibleHeal");
/// <summary>
/// Sound to play when Bible hits somebody.
/// </summary>
[DataField]
public SoundSpecifier BibleHitSound = new SoundCollectionSpecifier(DefaultBibleHitSound, AudioParams.Default.WithVolume(-4f));
/// <summary>
/// Sound to play when Bible fails to heal somebody.
/// </summary>
[DataField]
public SoundSpecifier SizzleSound = new SoundCollectionSpecifier(DefaultBibleSizzleSound);
/// <summary>
/// Sound to play when Bible heals somebody.
/// </summary>
[DataField]
public SoundSpecifier HealSound = new SoundCollectionSpecifier(DefaultBibleHealSound);
/// <summary>
/// Damage that will be healed on a success.
/// </summary>
[DataField(required: true)]
public DamageSpecifier Damage = default!;
/// <summary>
/// Damage that will be dealt on a failure.
/// </summary>
[DataField(required: true)]
public DamageSpecifier DamageOnFail = default!;
/// <summary>
/// Damage that will be dealt when a non-chaplain attempts to heal.
/// </summary>
[DataField(required: true)]
public DamageSpecifier DamageOnUntrainedUse = default!;
/// <summary>
/// Chance theBbible will fail to heal someone with no helmet.
/// </summary>
[DataField]
public float FailChance = 0.34f;
/// <summary>
/// Loc string played when a non-chaplain attempts to use the bible.
/// </summary>
[DataField]
public string SizzleText = "bible-sizzle";
/// <summary>
/// Loc string shown to others when the bible fails to heal the target.
/// </summary>
[DataField]
public string HealFailOthersText = "bible-heal-fail-others";
/// <summary>
/// Loc string shown to the user when the bible fails to heal the target.
/// </summary>
[DataField]
public string HealFailSelfText = "bible-heal-fail-self";
/// <summary>
/// Loc string shown to others when the bible successfully heals the target.
/// </summary>
[DataField]
public string HealSuccessOthersText = "bible-heal-success-others";
/// <summary>
/// Loc string shown to the user when the bible successfully heals the target.
/// </summary>
[DataField]
public string HealSuccessSelfText = "bible-heal-success-self";
/// <summary>
/// Loc string shown to others when the bible hits a target without wounds.
/// </summary>
[DataField]
public string HealSuccessNoneOthersText = "bible-heal-success-none-others";
/// <summary>
/// Loc string shown to the user when the bible hits a target without wounds.
/// </summary>
[DataField]
public string HealSuccessNoneSelfText = "bible-heal-success-none-self";
/// <summary>
/// A short light effect to display when successfully healing someone.
/// </summary>
[DataField]
public EntProtoId? HealingLightEffect = "HolyLightEffect";
}

View file

@ -0,0 +1,13 @@
using Robust.Shared.GameStates;
namespace Content.Shared.Bible.Components;
/// <summary>
/// Marks entity as bible user.
/// </summary>
[RegisterComponent, NetworkedComponent]
[Access(typeof(BibleSystem))]
public sealed partial class BibleUserComponent : Component
{
public override bool SendOnlyToOwner => true;
}

View file

@ -0,0 +1,19 @@
using Robust.Shared.GameStates;
namespace Content.Shared.Bible.Components;
/// <summary>
/// This component is for the Bible user's familiars, and mostly
/// used to track their current state and to give a component to check for
/// if any special behavior is needed.
/// </summary>
[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
[Access(typeof(BibleSystem))]
public sealed partial class FamiliarComponent : Component
{
/// <summary>
/// The entity this familiar was summoned from.
/// </summary>
[ViewVariables, AutoNetworkedField]
public EntityUid? Source = null;
}

View file

@ -0,0 +1,91 @@
using Robust.Shared.Audio;
using Robust.Shared.GameStates;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
namespace Content.Shared.Bible.Components;
/// <summary>
/// This lets you summon a mob or item with an alternative verb on the item
/// </summary>
[RegisterComponent, NetworkedComponent, AutoGenerateComponentState, AutoGenerateComponentPause]
[Access(typeof(BibleSystem))]
public sealed partial class SummonableComponent : Component
{
/// <summary>
/// Default sound to play when entity is summoned.
/// </summary>
private static readonly ProtoId<SoundCollectionPrototype> DefaultSummonSound = new("Summon");
/// <summary>
/// Sound to play when entity is summoned.
/// </summary>
[DataField]
public SoundSpecifier SummonSound = new SoundCollectionSpecifier(DefaultSummonSound, AudioParams.Default.WithVolume(-4f));
/// <summary>
/// Used for a special item only the Chaplain can summon. Usually a mob, but supports regular items too.
/// </summary>
[DataField("summonEntity")]
public EntProtoId? SummonEntityPrototype;
/// <summary>
/// The summon action for component to use.
/// </summary>
[DataField("summonAction")]
public EntProtoId SummonActionPrototype = "ActionBibleSummon";
/// <summary>
/// The specific creature this summoned, if the SpecialItemPrototype has a mobstate.
/// </summary>
[ViewVariables, AutoNetworkedField]
public EntityUid? SummonedEntity = null;
/// <summary>
/// The created summon action.
/// </summary>
[DataField, AutoNetworkedField]
public EntityUid? SummonActionEntity;
/// <summary>
/// Whether or not user must have <see cref="BibleUserComponent"/>.
/// </summary>
[DataField]
public bool RequiresBibleUser = true;
/// <summary>
/// Whether or not summoning is allowed at the moment.
/// </summary>
[ViewVariables, AutoNetworkedField]
public bool CanSummon = true;
/// <summary>
/// Loc string for the summon alt-verb text.
/// </summary>
[DataField]
public string SummonVerbText = "bible-summon-verb";
/// <summary>
/// Loc string shown when a summon has been requested and is awaiting a soul.
/// </summary>
[DataField]
public string SummonRequestedText = "bible-summon-requested";
/// <summary>
/// Loc string shown when the familiar has respawned and is ready to summon again.
/// </summary>
[DataField]
public string SummonRespawnReadyText = "bible-summon-respawn-ready";
/// <summary>
/// Cooldown before being able to summon a new entity.
/// </summary>
[DataField]
public TimeSpan RespawnCooldown = TimeSpan.FromSeconds(180);
/// <summary>
/// Time after which summoning will be allowed.
/// </summary>
[DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoPausedField, AutoNetworkedField]
public TimeSpan RespawnTime = TimeSpan.Zero;
}

View file

@ -0,0 +1,10 @@
using Robust.Shared.GameStates;
namespace Content.Shared.Bible.Components;
/// <summary>
/// EntityQuery tracking component for summonables that are waiting for a respawn.
/// </summary>
[RegisterComponent, NetworkedComponent]
[Access(typeof(BibleSystem))]
public sealed partial class SummonableRespawningComponent : Component;

View file

@ -2,7 +2,7 @@
namespace Content.Shared.Bible;
public sealed partial class SummonActionEvent : InstantActionEvent
{
}
/// <summary>
/// Action event for summoning.
/// </summary>
public sealed partial class SummonActionEvent : InstantActionEvent;

View file

@ -0,0 +1,9 @@
namespace Content.Shared.Ghost;
/// <summary>
/// Raised on a spawned entity after they use a ghost role mob spawner.
/// </summary>
/// <param name="Spawner">The entity that spawned this.</param>
/// <param name="Spawned">The entity spawned.</param>
[ByRefEvent]
public readonly record struct GhostRoleSpawnerUsedEvent(EntityUid Spawner, EntityUid Spawned);

View file

@ -34,7 +34,7 @@
- type: Prayable
bibleUserOnly: true
- type: Summonable
specialItem: SpawnPointGhostRemilia
summonEntity: SpawnPointGhostRemilia
- type: ReactionMixer
mixMessage: "bible-mixing-success"
reactionTypes:
@ -81,11 +81,14 @@
types:
Caustic: 50
failChance: 0
locPrefix: "necro"
healFailOthersText: necro-heal-fail-others
healFailSelfText: necro-heal-fail-self
healSuccessOthersText: necro-heal-success-others
healSuccessSelfText: necro-heal-success-self
healSound: "/Audio/Effects/lightburn.ogg"
healingLightEffect: EvilLightEffect
- type: Summonable
specialItem: SpawnPointGhostCerberus
summonEntity: SpawnPointGhostCerberus
respawnTime: 300
- type: Sprite
sprite: Objects/Specific/Chapel/necronomicon.rsi

View file

@ -7,3 +7,13 @@
id: BibleHit
files:
- /Audio/Effects/hit_kick.ogg
- type: soundCollection
id: BibleSizzle
files:
- /Audio/Effects/lightburn.ogg
- type: soundCollection
id: BibleHeal
files:
- /Audio/Effects/holy.ogg