using SoulHarvest.Items;
using SoulHarvest.Projectiles;
using SoulHarvest.UI;
using Microsoft.Xna.Framework;
using System;
using System.Collections.Generic;
using System.IO;
using Terraria;
using Terraria.Audio;
using Terraria.Chat;
using Terraria.ID;
using Terraria.GameInput;
using Terraria.Localization;
using Terraria.ModLoader;
using Terraria.ModLoader.IO;
namespace SoulHarvest.Common;
public class MyPlayer : ModPlayer
{
public const int StartingSouls = 150;
public const int SoulsPerEssence = 100;
internal const int DeathWingTrailCapacity = 28;
private const int DeathWingBodyRiftIdentity = -173870001;
private const int DeathWingDomainChunkCapacity = 14;
private const int DeathWingDomainChunkOverlapPoints = 4;
public int Souls { get; private set; } = StartingSouls;
public int SickleAlternateCooldown { get; private set; }
public int SickleAlternateCooldownMax { get; private set; }
public bool DeathDomainEnabled { get; private set; }
public bool DeathDomainDescended { get; private set; }
public DeathDomainProgression DeathDomainProgression { get; } = new();
public ReaperProgressionState ReaperProgression { get; } = new();
public int ReaperSpecialCooldown { get; private set; }
public int ReaperSpecialCooldownMax { get; private set; }
public int ReaperExhaustionCooldown { get; private set; }
public ReaperFormId VisibleReaperForm { get; private set; } = ReaperFormId.Base;
public ReaperStage VisibleReaperStage { get; private set; } = ReaperStage.StageI;
internal DeathNecklace? ActiveDeathNecklace { get; private set; }
internal bool DeathWingsActiveThisTick { get; set; }
internal bool DeathWingsActiveLastTick { get; private set; }
internal Vector2[] DeathWingUpperTrail { get; } = new Vector2[DeathWingTrailCapacity];
internal Vector2[] DeathWingLowerTrail { get; } = new Vector2[DeathWingTrailCapacity];
internal Vector2[] DeathWingBodyTrail { get; } = new Vector2[DeathWingTrailCapacity];
internal int DeathWingTrailCount { get; private set; }
internal float DeathWingTrailMastery { get; private set; }
internal float DeathWingTrailOpacity { get; private set; }
internal float DeathWingRiftStrength { get; private set; }
private int lifeStealCooldown;
private int swingComboDirection = 1;
private bool reaperPrimaryInputWasHeld;
private bool rawSpecialInputWasHeld;
private bool serverSoulBalanceInitialized;
private bool serverReaperProgressionInitialized;
private int clientCharacterStateUploadDelay;
private int clientCharacterStateUploadAttempts;
private bool clientCharacterStateAcknowledged;
private bool reaperDeathStateCleared;
private bool reaperLegacyMigrationChecked;
private int reaperLegacyMigrationVersion;
private int pendingLegacyMigrationEssence;
private TagCompound? reaperLegacyMigrationSnapshot;
private int reaperLegacyMigrationNoticeWeapons;
private int reaperLegacyMigrationNoticeRefund;
private readonly float[] reaperEnergy = new float[8];
private readonly int[] reaperUltimateCooldowns = new int[8];
private byte deathCycleMask;
private ReaperFormId? queuedReaperForm;
private ulong lastReaperFormSwitchRequestTick;
private ulong deathWingTrailUpdateTick = ulong.MaxValue;
private ulong deathWingDomainCombatUpdateTick = ulong.MaxValue;
private readonly List<Vector2> deathWingDomainChunkPoints = [];
private readonly List<float> deathWingDomainChunkOpacities = [];
private readonly ulong[] deathWingDomainNextHarvestTicks =
new ulong[Main.maxNPCs];
private float deathWingDomainChunkMastery;
private bool deathWingDomainChunkHasNewGeometry;
private int deathWingDomainChunkSequence;
private int deathWingDomainVolleyCounter;
private int deathDomainTimer;
private int deathDomainVolleyCounter;
private int deathDomainKeyHoldFrames;
private bool deathDomainLongPressHandled;
private int deathDomainScreenShakeFrames;
private int deathDomainSlashSoundCooldown;
private float deathDomainScreenShakeStrength;
private Vector2 deathDomainScreenShakeDirection = Vector2.UnitY;
private readonly Dictionary<int, PendingDeathDomainCut> deathDomainPendingCuts = [];
private readonly List<PendingDeathDomainStrike> pendingDeathDomainStrikes = [];
private readonly List<PendingReaperDomainHarvest> pendingReaperDomainHarvests = [];
private const int DeathDomainLongPressFrames = 45;
private readonly record struct PendingDeathDomainCut(float Rotation, int ProjectileIndex);
private readonly record struct PendingDeathDomainStrike(
int NpcIndex,
int RemainingFrames,
int Damage,
int LifeStealLevel,
int DeathUltimateActionId);
private readonly record struct PendingReaperDomainHarvest(
int NpcIndex,
int RemainingFrames,
float Rotation,
float VisualMastery,
bool Requiem,
int DeathUltimateActionId);
public override void Initialize()
{
Souls = StartingSouls;
lifeStealCooldown = 0;
swingComboDirection = 1;
reaperPrimaryInputWasHeld = false;
rawSpecialInputWasHeld = false;
SickleAlternateCooldown = 0;
SickleAlternateCooldownMax = 0;
DeathWingsActiveThisTick = false;
DeathWingsActiveLastTick = false;
ClearDeathWingTrail();
DiscardDeathWingDomainChunk();
deathWingDomainCombatUpdateTick = ulong.MaxValue;
Array.Clear(deathWingDomainNextHarvestTicks);
deathWingDomainChunkSequence = 0;
deathWingDomainVolleyCounter = 0;
serverSoulBalanceInitialized = false;
serverReaperProgressionInitialized = false;
clientCharacterStateUploadDelay = 0;
clientCharacterStateUploadAttempts = 0;
clientCharacterStateAcknowledged = false;
DeathDomainProgression.Reset();
ReaperProgression.Reset();
VisibleReaperForm = ReaperFormId.Base;
VisibleReaperStage = ReaperStage.StageI;
ClearReaperCombatRuntime(sync: false);
reaperDeathStateCleared = false;
queuedReaperForm = null;
lastReaperFormSwitchRequestTick = 0;
reaperLegacyMigrationChecked = false;
reaperLegacyMigrationVersion = 0;
pendingLegacyMigrationEssence = 0;
reaperLegacyMigrationSnapshot = null;
reaperLegacyMigrationNoticeWeapons = 0;
reaperLegacyMigrationNoticeRefund = 0;
DeathDomainEnabled = false;
DeathDomainDescended = false;
ActiveDeathNecklace = null;
deathDomainTimer = 0;
deathDomainVolleyCounter = 0;
deathDomainKeyHoldFrames = 0;
deathDomainLongPressHandled = false;
deathDomainScreenShakeFrames = 0;
deathDomainSlashSoundCooldown = 0;
deathDomainScreenShakeStrength = 0f;
deathDomainScreenShakeDirection = Vector2.UnitY;
deathDomainPendingCuts.Clear();
pendingDeathDomainStrikes.Clear();
pendingReaperDomainHarvests.Clear();
}
public override void ResetEffects()
{
DeathWingsActiveThisTick = false;
ActiveDeathNecklace = null;
}
public override void SaveData(TagCompound tag)
{
tag[nameof(Souls)] = Souls;
tag[nameof(DeathDomainEnabled)] = DeathDomainEnabled;
tag[nameof(DeathDomainDescended)] = DeathDomainDescended;
DeathDomainProgression.SaveData(tag);
ReaperProgression.SaveData(tag);
tag["ReaperLegacyMigrationVersion"] = reaperLegacyMigrationVersion;
tag["PendingLegacyMigrationEssence"] = pendingLegacyMigrationEssence;
if (reaperLegacyMigrationSnapshot is not null)
tag["ReaperLegacyMigrationSnapshot"] = reaperLegacyMigrationSnapshot;
}
public override void LoadData(TagCompound tag)
{
Souls = tag.ContainsKey(nameof(Souls)) ? Math.Max(0, tag.GetInt(nameof(Souls))) : StartingSouls;
DeathDomainEnabled = tag.GetBool(nameof(DeathDomainEnabled));
DeathDomainDescended = DeathDomainEnabled && tag.GetBool(nameof(DeathDomainDescended));
DeathDomainProgression.LoadData(tag);
ReaperProgression.LoadData(tag);
reaperLegacyMigrationVersion = tag.ContainsKey("ReaperLegacyMigrationVersion")
? Math.Max(0, tag.GetInt("ReaperLegacyMigrationVersion"))
: 0;
pendingLegacyMigrationEssence = Math.Max(0, tag.GetInt("PendingLegacyMigrationEssence"));
reaperLegacyMigrationSnapshot = tag.ContainsKey("ReaperLegacyMigrationSnapshot")
? tag.GetCompound("ReaperLegacyMigrationSnapshot")
: null;
// Player inventory and personal banks have already been deserialized when
// ModPlayer data is loaded. Migrating here guarantees the authoritative
// tree is ready before the first multiplayer SyncPlayer upload.
if (reaperLegacyMigrationVersion < ReaperLegacyMigration.MigrationVersion)
{
ReaperLegacyMigrationResult result = ReaperLegacyMigration.Apply(Player, ReaperProgression);
reaperLegacyMigrationVersion = ReaperLegacyMigration.MigrationVersion;
pendingLegacyMigrationEssence = Math.Max(0, pendingLegacyMigrationEssence + result.RefundEssence);
reaperLegacyMigrationSnapshot = result.Snapshot;
reaperLegacyMigrationNoticeWeapons = result.ConvertedWeaponCount;
reaperLegacyMigrationNoticeRefund = result.RefundEssence;
TryDeliverPendingLegacyMigrationRefund();
}
}
public override void OnEnterWorld()
{
ReaperCombatService.ResetPlayerState(Player.whoAmI);
ClearReaperCombatRuntime(sync: false);
if (Main.netMode == NetmodeID.MultiplayerClient
&& Player.whoAmI == Main.myPlayer)
{
// ModPlayer.SyncPlayer is not guaranteed to be invoked client-side
// after every join/reload. Explicitly retry the character-owned
// progression upload so the server cannot remain permanently in
// its uninitialized state and reject combat, forms and the domain.
clientCharacterStateUploadDelay = 1;
clientCharacterStateUploadAttempts = 0;
clientCharacterStateAcknowledged = false;
}
if (Main.netMode == NetmodeID.Server || Player.whoAmI != Main.myPlayer || reaperLegacyMigrationChecked)
return;
reaperLegacyMigrationChecked = true;
TryDeliverPendingLegacyMigrationRefund();
if (reaperLegacyMigrationNoticeWeapons > 0)
{
Main.NewText(
Language.GetTextValue(
"Mods.SoulHarvest.Messages.ReaperMigrationComplete",
reaperLegacyMigrationNoticeWeapons,
reaperLegacyMigrationNoticeRefund),
new Color(205, 90, 235));
reaperLegacyMigrationNoticeWeapons = 0;
reaperLegacyMigrationNoticeRefund = 0;
}
}
public override void PostUpdate()
{
UpdateClientCharacterStateUpload();
if (Main.netMode != NetmodeID.Server && Player.whoAmI == Main.myPlayer && pendingLegacyMigrationEssence > 0)
TryDeliverPendingLegacyMigrationRefund();
if (lifeStealCooldown > 0)
lifeStealCooldown--;
if (SickleAlternateCooldown > 0)
SickleAlternateCooldown--;
if (ReaperSpecialCooldown > 0)
ReaperSpecialCooldown--;
if (ReaperExhaustionCooldown > 0)
ReaperExhaustionCooldown--;
for (int formIndex = 0; formIndex < reaperUltimateCooldowns.Length; formIndex++)
{
if (reaperUltimateCooldowns[formIndex] > 0)
reaperUltimateCooldowns[formIndex]--;
}
TryApplyQueuedReaperForm();
if (deathDomainSlashSoundCooldown > 0)
deathDomainSlashSoundCooldown--;
UpdatePendingReaperDomainHarvests();
UpdatePendingDeathDomainStrikes();
UpdateDeathDomain();
if (Player.dead)
{
if (!reaperDeathStateCleared)
{
ClearReaperCombatRuntime(sync: true);
reaperDeathStateCleared = true;
}
ClearDeathWingTrail();
DiscardDeathWingDomainChunk();
}
else if (!DeathWingsActiveThisTick && DeathWingTrailOpacity > 0f)
{
DeathWingTrailOpacity *= 0.78f;
if (DeathWingTrailOpacity < 0.025f)
ClearDeathWingTrail();
}
if (!Player.dead && DeathWingTrailOpacity > 0f)
SubmitDeathWingDomainRifts();
if (!Player.dead && !DeathWingsActiveThisTick
&& Main.netMode != NetmodeID.MultiplayerClient)
{
SpawnDeathWingDomainChunk(keepTail: false);
}
if (!Player.dead)
reaperDeathStateCleared = false;
DeathWingsActiveLastTick = DeathWingsActiveThisTick;
}
private void UpdateClientCharacterStateUpload()
{
if (Main.netMode != NetmodeID.MultiplayerClient
|| Player.whoAmI != Main.myPlayer
|| clientCharacterStateAcknowledged)
{
return;
}
if (clientCharacterStateUploadDelay > 0)
{
clientCharacterStateUploadDelay--;
if (clientCharacterStateUploadDelay > 0)
return;
}
SoulHarvest.SendReaperProgression(Player);
clientCharacterStateUploadAttempts++;
clientCharacterStateUploadDelay = clientCharacterStateUploadAttempts switch
{
1 => 30,
2 => 90,
3 => 180,
_ => 300
};
}
internal void AcknowledgeServerCharacterState()
{
if (Main.netMode == NetmodeID.MultiplayerClient
&& Player.whoAmI == Main.myPlayer)
{
clientCharacterStateAcknowledged = true;
clientCharacterStateUploadDelay = 0;
}
}
#if DEBUG
internal bool ClientCharacterStateAcknowledged
=> clientCharacterStateAcknowledged;
#endif
private void TryDeliverPendingLegacyMigrationRefund()
{
if (pendingLegacyMigrationEssence <= 0)
return;
int essenceType = ModContent.ItemType<Soul>();
int slotCount = Math.Min(58, Player.inventory.Length);
for (int slot = 0; slot < slotCount && pendingLegacyMigrationEssence > 0; slot++)
{
Item item = Player.inventory[slot];
if (item.type != essenceType || item.stack <= 0 || item.stack >= item.maxStack)
continue;
int inserted = Math.Min(pendingLegacyMigrationEssence, item.maxStack - item.stack);
item.stack += inserted;
pendingLegacyMigrationEssence -= inserted;
SyncMigrationInventorySlot(slot);
}
for (int slot = 0; slot < slotCount && pendingLegacyMigrationEssence > 0; slot++)
{
Item item = Player.inventory[slot];
if (!item.IsAir)
continue;
item.SetDefaults(essenceType);
int inserted = Math.Min(pendingLegacyMigrationEssence, item.maxStack);
item.stack = inserted;
pendingLegacyMigrationEssence -= inserted;
SyncMigrationInventorySlot(slot);
}
}
private void SyncMigrationInventorySlot(int slot)
{
if (Main.netMode != NetmodeID.MultiplayerClient)
return;
NetMessage.SendData(
MessageID.SyncEquipment,
-1,
-1,
null,
Player.whoAmI,
PlayerItemSlotID.Inventory0 + slot,
Player.inventory[slot].prefix);
}
public override void ModifyScreenPosition()
{
if (deathDomainScreenShakeFrames > 0 && deathDomainScreenShakeStrength > 0.01f)
{
float remaining = deathDomainScreenShakeFrames / 6f;
float phase = (6 - deathDomainScreenShakeFrames) * 2.45f;
Vector2 tangent = deathDomainScreenShakeDirection.RotatedBy(MathHelper.PiOver2);
Main.screenPosition += deathDomainScreenShakeDirection * ((float)Math.Sin(phase) * deathDomainScreenShakeStrength * remaining)
+ tangent * ((float)Math.Sin(phase * 0.63f + 1.1f) * deathDomainScreenShakeStrength * remaining * 0.32f);
deathDomainScreenShakeFrames--;
if (deathDomainScreenShakeFrames <= 0)
deathDomainScreenShakeStrength = 0f;
}
ReaperVfxDirector.ApplyCameraImpulse(ref Main.screenPosition);
}
internal void TriggerDeathDomainImpact(Vector2 worldPosition, Vector2 direction, float strength)
{
if (Main.dedServ || Player.whoAmI != Main.myPlayer)
return;
float distance = Vector2.Distance(Player.Center, worldPosition);
float falloff = 1f - MathHelper.Clamp(distance / 1200f, 0f, 1f);
strength *= falloff * falloff;
if (strength <= deathDomainScreenShakeStrength)
return;
deathDomainScreenShakeStrength = Math.Min(5.2f, strength);
deathDomainScreenShakeDirection = direction.SafeNormalize(Vector2.UnitY);
deathDomainScreenShakeFrames = 6;
}
internal void PlayDeathDomainSlashSound(Vector2 worldPosition, float mastery, bool requiem)
{
if (Main.dedServ
|| Player.whoAmI != Main.myPlayer
|| deathDomainSlashSoundCooldown > 0
|| Vector2.DistanceSquared(Player.Center, worldPosition) > 1600f * 1600f)
{
return;
}
deathDomainSlashSoundCooldown = 4;
SoundEngine.PlaySound(SoundID.Item71, worldPosition);
}
internal void RecordDeathWingTrail(Vector2 upperTip, Vector2 lowerTip,
float mastery)
{
if (Main.dedServ || deathWingTrailUpdateTick == Main.GameUpdateCount)
return;
deathWingTrailUpdateTick = Main.GameUpdateCount;
if (DeathWingTrailCount > 0
&& (Vector2.DistanceSquared(upperTip, DeathWingUpperTrail[0]) > 120f * 120f
|| Vector2.DistanceSquared(lowerTip, DeathWingLowerTrail[0]) > 120f * 120f
|| Vector2.DistanceSquared(Player.Center, DeathWingBodyTrail[0]) > 120f * 120f))
{
ClearDeathWingTrail();
}
int copyCount = Math.Min(DeathWingTrailCount, DeathWingTrailCapacity - 1);
if (copyCount > 0)
{
Array.Copy(DeathWingUpperTrail, 0, DeathWingUpperTrail, 1, copyCount);
Array.Copy(DeathWingLowerTrail, 0, DeathWingLowerTrail, 1, copyCount);
Array.Copy(DeathWingBodyTrail, 0, DeathWingBodyTrail, 1, copyCount);
}
DeathWingUpperTrail[0] = upperTip;
DeathWingLowerTrail[0] = lowerTip;
DeathWingBodyTrail[0] = Player.Center;
DeathWingTrailCount = Math.Min(DeathWingTrailCount + 1, DeathWingTrailCapacity);
DeathWingTrailMastery = MathHelper.Clamp(mastery, 0f, 1f);
DeathWingTrailOpacity = 1f;
float speedProgress = MathHelper.Clamp(
(Player.velocity.Length() - 2.5f) / 15.5f, 0f, 1f);
DeathWingRiftStrength = speedProgress;
}
internal void RecordDeathWingDomainTrail(float mastery)
{
if (Main.netMode == NetmodeID.MultiplayerClient
|| deathWingDomainCombatUpdateTick == Main.GameUpdateCount)
{
return;
}
deathWingDomainCombatUpdateTick = Main.GameUpdateCount;
float speedProgress = MathHelper.Clamp(
(Player.velocity.Length() - 2.5f) / 15.5f, 0f, 1f);
if (speedProgress <= 0.035f)
{
SpawnDeathWingDomainChunk(keepTail: false);
return;
}
float opacity = GetDeathWingRiftOpacity(speedProgress);
Vector2 point = Player.Center;
if (deathWingDomainChunkPoints.Count > 0
&& Vector2.DistanceSquared(point,
deathWingDomainChunkPoints[^1]) > 120f * 120f)
{
SpawnDeathWingDomainChunk(keepTail: false);
}
if (deathWingDomainChunkPoints.Count >= 6)
{
float averageOpacity = 0f;
foreach (float sample in deathWingDomainChunkOpacities)
averageOpacity += sample;
averageOpacity /= deathWingDomainChunkOpacities.Count;
if (Math.Abs(opacity - averageOpacity) >= 0.16f)
SpawnDeathWingDomainChunk(keepTail: true);
}
if (deathWingDomainChunkPoints.Count > 0
&& Vector2.DistanceSquared(point,
deathWingDomainChunkPoints[^1]) < 2.25f * 2.25f)
{
deathWingDomainChunkPoints[^1] = point;
deathWingDomainChunkOpacities[^1] = opacity;
deathWingDomainChunkMastery = Math.Max(
deathWingDomainChunkMastery,
MathHelper.Clamp(mastery, 0f, 1f));
return;
}
deathWingDomainChunkPoints.Add(point);
deathWingDomainChunkOpacities.Add(opacity);
deathWingDomainChunkHasNewGeometry = true;
deathWingDomainChunkMastery = Math.Max(deathWingDomainChunkMastery,
MathHelper.Clamp(mastery, 0f, 1f));
if (deathWingDomainChunkPoints.Count >= DeathWingDomainChunkCapacity)
SpawnDeathWingDomainChunk(keepTail: true);
}
internal bool TrySpawnDeathWingDomainTrailHarvest(NPC target, int seed)
{
if (Main.netMode == NetmodeID.MultiplayerClient
|| !ReaperTargeting.IsValidWeaponTarget(target))
{
return false;
}
int npcIndex = target.whoAmI;
ulong currentTick = Main.GameUpdateCount;
if (deathWingDomainNextHarvestTicks[npcIndex] > currentTick)
return false;
deathWingDomainNextHarvestTicks[npcIndex] = currentTick
+ (ulong)Math.Max(1, DeathDomainProgression.SpawnInterval);
deathWingDomainVolleyCounter++;
uint hash = unchecked((uint)(seed * 16777619
+ npcIndex * 486187739 + deathWingDomainVolleyCounter * 97));
hash ^= hash >> 16;
float rotation = (hash & 0x00FFFFFFu) / 16777215f
* MathHelper.TwoPi;
bool requiem = IsDeathDomainDescended(GetEquippedDeathNecklace())
&& deathWingDomainVolleyCounter % 4 == 0;
SpawnDeathDomainHarvestFromReaperRift(target, requiem, rotation);
return true;
}
private void SpawnDeathWingDomainChunk(bool keepTail)
{
int count = deathWingDomainChunkPoints.Count;
if (count < 2 || !deathWingDomainChunkHasNewGeometry)
{
if (!keepTail)
DiscardDeathWingDomainChunk();
return;
}
int retainedCount = keepTail
? Math.Min(DeathWingDomainChunkOverlapPoints, count)
: 0;
Span<Vector2> retainedPoints = stackalloc Vector2[
DeathWingDomainChunkOverlapPoints];
Span<float> retainedOpacities = stackalloc float[
DeathWingDomainChunkOverlapPoints];
for (int index = 0; index < retainedCount; index++)
{
int sourceIndex = count - retainedCount + index;
retainedPoints[index] = deathWingDomainChunkPoints[sourceIndex];
retainedOpacities[index] =
deathWingDomainChunkOpacities[sourceIndex];
}
float retainedMastery = deathWingDomainChunkMastery;
float opacity = 0f;
foreach (float sample in deathWingDomainChunkOpacities)
opacity += sample;
opacity /= deathWingDomainChunkOpacities.Count;
float width = 32f + deathWingDomainChunkMastery * 8f;
deathWingDomainChunkSequence = unchecked(
deathWingDomainChunkSequence + 1);
DeathWingDomainTrailProjectile.Spawn(
Player.GetSource_Misc("SoulHarvest:DeathWingDomainTrail"),
Player.whoAmI, deathWingDomainChunkPoints, width, opacity,
deathWingDomainChunkSequence);
DiscardDeathWingDomainChunk();
if (!keepTail)
return;
for (int index = 0; index < retainedCount; index++)
{
deathWingDomainChunkPoints.Add(retainedPoints[index]);
deathWingDomainChunkOpacities.Add(retainedOpacities[index]);
}
deathWingDomainChunkMastery = retainedMastery;
deathWingDomainChunkHasNewGeometry = false;
}
private void DiscardDeathWingDomainChunk()
{
deathWingDomainChunkPoints.Clear();
deathWingDomainChunkOpacities.Clear();
deathWingDomainChunkMastery = 0f;
deathWingDomainChunkHasNewGeometry = false;
}
private static float GetDeathWingRiftOpacity(float speedProgress)
{
speedProgress = MathHelper.Clamp(speedProgress, 0f, 1f);
float smoothedSpeed = speedProgress * speedProgress
* (3f - 2f * speedProgress);
return MathHelper.Lerp(0.08f, 0.92f, smoothedSpeed);
}
private void SubmitDeathWingDomainRifts()
{
if (Main.dedServ || DeathWingTrailCount < 4
|| DeathWingRiftStrength <= 0.035f)
{
return;
}
int pointCount = Math.Min(DeathWingTrailCount,
8 + (int)(DeathWingTrailMastery * 10f));
float width = 32f + DeathWingTrailMastery * 8f;
float opacity = DeathWingTrailOpacity
* GetDeathWingRiftOpacity(DeathWingRiftStrength);
DeathDomainTrailVisualSystem.Record(Player.whoAmI,
DeathWingBodyRiftIdentity,
new ArraySegment<Vector2>(DeathWingBodyTrail, 0, pointCount),
width, opacity, mergeOverlappingRims: true,
taperEnds: false, noiseFadeEnds: true);
}
private void ClearDeathWingTrail()
{
DeathWingTrailCount = 0;
DeathWingTrailMastery = 0f;
DeathWingTrailOpacity = 0f;
DeathWingRiftStrength = 0f;
deathWingTrailUpdateTick = ulong.MaxValue;
}
public override void SyncPlayer(int toWho, int fromWho, bool newPlayer)
{
if (Main.netMode == NetmodeID.SinglePlayer
|| Main.netMode == NetmodeID.MultiplayerClient && Player.whoAmI != Main.myPlayer)
{
return;
}
// A joining client uploads souls, domain state and the full Reaper tree in
// one packet. Splitting those character-owned values allowed mutations to
// occur between two independently accepted initialization packets.
if (Main.netMode == NetmodeID.MultiplayerClient)
{
SoulHarvest.SendReaperProgression(Player, toWho, fromWho);
return;
}
if (!ServerCharacterStateReady)
return;
ModPacket packet = Mod.GetPacket();
packet.Write((byte)SoulHarvest.MessageType.SyncSoulPlayer);
packet.Write((byte)Player.whoAmI);
packet.Write(Souls);
packet.Write(DeathDomainEnabled);
packet.Write(DeathDomainDescended);
DeathDomainProgression.NetSend(packet);
packet.Send(toWho, fromWho);
SoulHarvest.SendReaperProgression(Player, toWho, fromWho);
SoulHarvest.SendReaperCombat(Player, toWho, fromWho);
}
internal void ReceiveSouls(int souls)
{
Souls = Math.Max(0, souls);
}
internal void ReceiveDeathDomainState(bool enabled, bool descended)
{
bool opening = enabled && !DeathDomainEnabled;
bool descending = enabled && descended && !DeathDomainDescended;
DeathDomainEnabled = enabled;
DeathDomainDescended = enabled && descended;
if (!enabled)
deathDomainTimer = 0;
if ((opening || descending) && Main.netMode != NetmodeID.Server && !Main.dedServ)
{
// Two restrained layers give the expansion both a low spatial rupture
// and a sharp soul-metal opening without requiring a custom asset.
SoundEngine.PlaySound(SoundID.Roar with
{
Volume = 0.42f,
Pitch = -0.48f,
PitchVariance = 0.06f
}, Player.Center);
SoundEngine.PlaySound(SoundID.Item122 with
{
Volume = 1.05f,
Pitch = -0.28f,
PitchVariance = 0.08f
}, Player.Center);
}
}
internal bool ServerCharacterStateReady => Main.netMode != NetmodeID.Server
|| serverSoulBalanceInitialized && serverReaperProgressionInitialized;
internal bool TryInitializeServerCharacterState(
int souls,
bool deathDomainEnabled,
bool deathDomainDescended,
BinaryReader reader)
{
if (Main.netMode != NetmodeID.Server)
return false;
if (serverSoulBalanceInitialized || serverReaperProgressionInitialized)
{
// The client retries until it receives an acknowledgement. Retried
// uploads are not authority, but their full payload must still be
// consumed or tModLoader disconnects the sender for an under-read.
DeathDomainProgression discardedDomain = new();
ReaperProgressionState discardedProgression = new();
discardedDomain.NetReceive(reader);
discardedProgression.NetReceive(reader);
return false;
}
Souls = Math.Max(0, souls);
DeathNecklace? necklace = FindEquippedDeathNecklace();
DeathDomainEnabled = deathDomainEnabled && necklace is not null;
DeathDomainDescended = DeathDomainEnabled
&& deathDomainDescended
&& necklace?.DeathDescentUnlocked == true;
DeathDomainProgression.NetReceive(reader);
ReaperProgression.NetReceive(reader);
VisibleReaperForm = ReaperCombatRegistry.ResolveUsableForm(ReaperProgression);
VisibleReaperStage = ReaperProgression.GetStage(VisibleReaperForm);
serverSoulBalanceInitialized = true;
serverReaperProgressionInitialized = true;
return true;
}
internal bool TrySpendSouls(int amount, bool sync = true)
{
if (amount < 0
|| Souls < amount
|| Main.netMode == NetmodeID.MultiplayerClient
|| !ServerCharacterStateReady)
return false;
Souls -= amount;
if (sync)
SyncSoulBalance();
return true;
}
public void AddSouls(int amount, bool announce = false)
{
if (amount <= 0 || Main.netMode == NetmodeID.MultiplayerClient || !ServerCharacterStateReady)
return;
amount = DeathDomainProgression.ApplySoulRefinement(amount);
Souls = (int)Math.Min(int.MaxValue, (long)Souls + amount);
SyncSoulBalance();
if (!announce)
return;
if (Main.netMode == NetmodeID.Server)
{
ChatHelper.SendChatMessageToClient(
NetworkText.FromKey("Mods.SoulHarvest.Messages.BossSoulReward", amount),
new Color(90, 235, 255),
Player.whoAmI);
}
else if (Player.whoAmI == Main.myPlayer)
{
Main.NewText(Language.GetTextValue("Mods.SoulHarvest.Messages.BossSoulReward", amount), new Color(90, 235, 255));
}
}
public bool TryExtractEssence()
{
if (Main.netMode == NetmodeID.MultiplayerClient || !ServerCharacterStateReady)
return false;
if (Souls < SoulsPerEssence)
{
SoulHarvest.SendOperationResult(Player, SoulHarvest.OperationResult.NotEnoughSouls);
return false;
}
int essenceType = ModContent.ItemType<Soul>();
if (!TryFindInventoryInsertionSlot(essenceType, out int changedSlot))
{
SoulHarvest.SendOperationResult(Player, SoulHarvest.OperationResult.InventoryFull);
return false;
}
Souls -= SoulsPerEssence;
Item destination = Player.inventory[changedSlot];
if (destination.type == essenceType && destination.stack > 0)
destination.stack++;
else
{
destination.SetDefaults(essenceType);
destination.stack = 1;
}
SoulHarvest.SendReaperProgressionTransaction(Player, new[] { changedSlot });
SoulHarvest.SendOperationResult(Player, SoulHarvest.OperationResult.EssenceExtracted);
return true;
}
public bool TryAbsorbEssence()
{
if (Main.netMode == NetmodeID.MultiplayerClient || !ServerCharacterStateReady)
return false;
int essenceType = ModContent.ItemType<Soul>();
int changedSlot = -1;
for (int slot = 0; slot < 58; slot++)
{
Item item = Player.inventory[slot];
if (item.type != essenceType || item.stack <= 0)
continue;
item.stack--;
if (item.stack <= 0)
item.TurnToAir();
changedSlot = slot;
break;
}
if (changedSlot < 0)
{
SoulHarvest.SendOperationResult(Player, SoulHarvest.OperationResult.NotEnoughEssence);
return false;
}
Souls = (int)Math.Min(int.MaxValue, (long)Souls + SoulsPerEssence);
SoulHarvest.SendReaperProgressionTransaction(Player, new[] { changedSlot });
SoulHarvest.SendOperationResult(Player, SoulHarvest.OperationResult.EssenceAbsorbed);
return true;
}
private bool TryFindInventoryInsertionSlot(int itemType, out int slot)
{
slot = -1;
int emptySlot = -1;
int slotCount = Math.Min(58, Player.inventory.Length);
for (int index = 0; index < slotCount; index++)
{
Item item = Player.inventory[index];
if (item.type == itemType && item.stack > 0 && item.stack < item.maxStack)
{
slot = index;
return true;
}
if (emptySlot < 0 && item.IsAir)
emptySlot = index;
}
slot = emptySlot;
return slot >= 0;
}
public int TryLifeSteal(int damageDone, int lifeStealLevel)
{
if (lifeStealLevel <= 0
|| damageDone <= 0
|| lifeStealCooldown > 0
|| Player.dead
|| Main.netMode == NetmodeID.MultiplayerClient && Player.whoAmI != Main.myPlayer)
{
return 0;
}
int missingLife = Player.statLifeMax2 - Player.statLife;
if (missingLife <= 0)
return 0;
int amount = Math.Min(missingLife, Math.Max(1, (int)Math.Ceiling(damageDone * lifeStealLevel * 0.01f)));
Player.Heal(amount);
lifeStealCooldown = 12;
if (Main.netMode != NetmodeID.SinglePlayer)
NetMessage.SendData(MessageID.PlayerLifeMana, -1, -1, null, Player.whoAmI);
return amount;
}
internal int ApplyDeathReaperLifeSteal(int damageDone, Vector2 visualSource,
int requestedLevel = 0)
{
if (damageDone <= 0 || Player.dead || Main.netMode == NetmodeID.MultiplayerClient)
return 0;
int lifeStealLevel = Math.Clamp(Math.Max(requestedLevel,
DeathDomainProgression.LifeStealLevel), 0, DeathDomainProgression.MaxLifeStealLevel);
if (lifeStealLevel <= 0)
return 0;
int missingLife = Math.Max(0, Player.statLifeMax2 - Player.statLife);
int perHitCap = Math.Max(1, (int)Math.Ceiling(Player.statLifeMax2 * 0.025f));
int amount = Math.Min(missingLife, Math.Min(perHitCap,
Math.Max(1, (int)Math.Ceiling(damageDone * lifeStealLevel * 0.01f))));
if (amount > 0)
{
Player.Heal(amount);
if (Main.netMode == NetmodeID.Server)
NetMessage.SendData(MessageID.PlayerLifeMana, -1, -1, null, Player.whoAmI);
}
// Death Reaper attacks always show the return stream, including at full life.
// This makes every successful slash readable without granting phantom healing.
LifeStealVisuals.Spawn(visualSource, Player.whoAmI, lifeStealLevel, amount,
forceFeedback: true);
return amount;
}
internal int TakeNextSwingDirection(int facingDirection)
{
int direction = swingComboDirection * facingDirection;
swingComboDirection *= -1;
return direction;
}
internal void StartSickleAlternateCooldown(int frames)
{
if (frames <= 0 || SickleAlternateCooldown > 0)
return;
SickleAlternateCooldown = frames;
SickleAlternateCooldownMax = frames;
}
public float GetReaperEnergy(ReaperFormId form)
{
int index = (int)form;
return index > (int)ReaperFormId.Base && index < reaperEnergy.Length
? MathHelper.Clamp(reaperEnergy[index], 0f, 100f)
: 0f;
}
public int GetReaperUltimateCooldown(ReaperFormId form)
{
int index = (int)form;
return index >= 0 && index < reaperUltimateCooldowns.Length
? Math.Max(0, reaperUltimateCooldowns[index])
: 0;
}
internal bool TryGainReaperEnergy(ReaperFormId form, float amount)
{
if (amount <= 0f || Main.netMode == NetmodeID.MultiplayerClient)
return false;
bool unlocked = form == ReaperFormId.Death
? ReaperProgression.DeathFormUnlocked
: ReaperDefinitions.IsBranchForm(form) && ReaperProgression.GetStage(form) == ReaperStage.StageIII;
int index = (int)form;
if (!unlocked || index <= (int)ReaperFormId.Base || index >= reaperEnergy.Length)
return false;
float gainMultiplier = 1f + ReaperProgression.GetCommonNodeLevel(ReaperCommonNode.EnergyGain) * 0.2f;
float previous = reaperEnergy[index];
reaperEnergy[index] = MathHelper.Clamp(previous + amount * gainMultiplier, 0f, 100f);
if ((int)previous != (int)reaperEnergy[index] || reaperEnergy[index] >= 100f)
SyncReaperCombat();
return reaperEnergy[index] > previous;
}
#if DEBUG
internal void DebugSetReaperEnergy(ReaperFormId form, float amount)
{
if (Main.netMode == NetmodeID.MultiplayerClient)
return;
int index = (int)form;
if (index <= (int)ReaperFormId.Base || index >= reaperEnergy.Length)
return;
reaperEnergy[index] = MathHelper.Clamp(amount, 0f, 100f);
reaperUltimateCooldowns[index] = 0;
ReaperExhaustionCooldown = 0;
SyncReaperCombat();
}
#endif
internal void RecordReaperAttackHit(ReaperFormId form, ReaperHitKind kind)
{
// Ultimate charge has one universal source: an attack actually connecting
// with an enemy. Damage-over-time ticks and the ultimate itself never feed
// the meter, while primary, derived, returning and special hits all count.
if (kind is ReaperHitKind.Ultimate or ReaperHitKind.UltimateDerived or ReaperHitKind.DamageOverTime)
return;
TryGainReaperEnergy(form, 4f);
}
internal bool TryStartReaperSpecialCooldown(int frames)
{
if (frames <= 0 || ReaperSpecialCooldown > 0)
return false;
float reduction = Math.Clamp(
ReaperProgression.GetCommonNodeLevel(ReaperCommonNode.SpecialCooldown) * 0.1f, 0f, 0.5f);
int adjustedFrames = Math.Max(30, (int)Math.Ceiling(frames * (1f - reduction)));
ReaperSpecialCooldown = adjustedFrames;
ReaperSpecialCooldownMax = adjustedFrames;
SyncReaperCombat();
return true;
}
internal void ReduceReaperSpecialCooldown(int frames)
{
if (frames <= 0 || Main.netMode == NetmodeID.MultiplayerClient || ReaperSpecialCooldown <= 0)
return;
int previous = ReaperSpecialCooldown;
ReaperSpecialCooldown = Math.Max(0, ReaperSpecialCooldown - frames);
if (ReaperSpecialCooldown != previous)
SyncReaperCombat();
}
internal void RefundFailedReaperSpecial()
{
if (Main.netMode == NetmodeID.MultiplayerClient)
return;
ReaperSpecialCooldown = 0;
ReaperSpecialCooldownMax = 0;
SyncReaperCombat();
}
internal bool TrySwitchOrQueueReaperForm(ReaperFormId form)
{
if (Main.netMode == NetmodeID.MultiplayerClient)
return false;
ulong now = Main.GameUpdateCount;
bool assemblyActive = ReaperFormAssemblyProjectile.TryGetActive(
Player, out ReaperFormAssemblyProjectile? activeAssembly);
if (lastReaperFormSwitchRequestTick != 0 && now - lastReaperFormSwitchRequestTick < 10UL)
{
// Wheel input can commit twice while closing. Treat an identical
// request as an idempotent acknowledgement instead of reporting a
// misleading progression/prerequisite failure to the client.
if (queuedReaperForm == form
|| activeAssembly?.TargetForm == form
|| ReaperProgression.CurrentForm == form)
return true;
}
lastReaperFormSwitchRequestTick = now;
if (!assemblyActive && ReaperProgression.CurrentForm == form)
return true;
if (IsReaperCombatActionBusy() || assemblyActive)
{
queuedReaperForm = form;
return true;
}
queuedReaperForm = null;
return TryStartReaperFormAssembly(form);
}
private void TryApplyQueuedReaperForm()
{
if (Main.netMode == NetmodeID.MultiplayerClient
|| queuedReaperForm is not ReaperFormId form
|| IsReaperCombatActionBusy()
|| ReaperFormAssemblyProjectile.TryGetActive(Player, out _))
return;
queuedReaperForm = null;
if (ReaperProgression.CurrentForm != form)
TryStartReaperFormAssembly(form);
}
private bool IsReaperCombatActionBusy()
{
return Player.itemAnimation > 0
|| Player.itemTime > 0
|| Player.ownedProjectileCounts[ModContent.ProjectileType<SickleSwingProjectile>()] > 0
|| Player.ownedProjectileCounts[ModContent.ProjectileType<ReaperActionControllerProjectile>()] > 0;
}
internal bool IsReaperFormAssemblyActive()
=> ReaperFormAssemblyProjectile.TryGetActive(Player, out _);
internal bool TryCompleteReaperFormAssembly(int projectileIndex,
ReaperFormId form)
{
if (Main.netMode == NetmodeID.MultiplayerClient
|| projectileIndex < 0 || projectileIndex >= Main.maxProjectiles)
{
return false;
}
Projectile projectile = Main.projectile[projectileIndex];
if (!projectile.active || projectile.owner != Player.whoAmI
|| projectile.ModProjectile
is not ReaperFormAssemblyProjectile assembly
|| assembly.TargetForm != form
|| !assembly.AssemblyComplete
|| !ReaperProgression.TrySetCurrentForm(form))
{
return false;
}
VisibleReaperForm = ReaperCombatRegistry.ResolveUsableForm(
ReaperProgression);
VisibleReaperStage = ReaperProgression.GetStage(VisibleReaperForm);
SyncReaperProgression();
return true;
}
private bool TryStartReaperFormAssembly(ReaperFormId form)
{
ReaperStage stage = form switch
{
ReaperFormId.Base => ReaperStage.StageI,
ReaperFormId.Death => ReaperStage.StageIII,
_ => ReaperProgression.GetStage(form)
};
int level = ReaperProgression.GetCommonNodeLevel(
ReaperCommonNode.AssemblySpeed);
int duration = ReaperDefinitions.GetAssemblyFrames(level);
int rangeLevel = ReaperProgression.GetCommonNodeLevel(
ReaperCommonNode.DeathRange);
bool started = ReaperFormAssemblyProjectile.Spawn(Player, form, stage,
duration, rangeLevel) >= 0;
#if DEBUG
Mod.Logger.Info(
$"SOULHARVEST_FORM_ASSEMBLY: start player={Player.whoAmI} from={ReaperProgression.CurrentForm} target={form} duration={duration} started={started}");
#endif
return started;
}
internal bool TryConsumeReaperUltimate(ReaperFormId form, int cooldownFrames)
{
if (Main.netMode == NetmodeID.MultiplayerClient
|| form != ReaperProgression.CurrentForm
|| ReaperExhaustionCooldown > 0
|| GetReaperUltimateCooldown(form) > 0
|| GetReaperEnergy(form) < 100f)
{
return false;
}
bool unlocked = form == ReaperFormId.Death
? ReaperProgression.DeathFormUnlocked
: ReaperDefinitions.IsBranchForm(form) && ReaperProgression.GetStage(form) == ReaperStage.StageIII;
if (!unlocked)
return false;
int index = (int)form;
reaperEnergy[index] = 0f;
reaperUltimateCooldowns[index] = Math.Max(1, cooldownFrames);
ReaperExhaustionCooldown = 60 * 8;
SyncReaperCombat();
return true;
}
internal void RefundFailedReaperUltimate(ReaperFormId form)
{
if (Main.netMode == NetmodeID.MultiplayerClient)
return;
int index = (int)form;
if (index <= (int)ReaperFormId.Base || index >= reaperEnergy.Length)
return;
reaperEnergy[index] = 100f;
reaperUltimateCooldowns[index] = 0;
ReaperExhaustionCooldown = 0;
SyncReaperCombat();
}
internal void RecordDeathCycleHit(int phase)
{
if (phase < 0 || phase >= 6 || ReaperProgression.CurrentForm != ReaperFormId.Death)
return;
byte bit = (byte)(1 << phase);
if ((deathCycleMask & bit) != 0)
return;
deathCycleMask |= bit;
if (deathCycleMask == 0x3F)
deathCycleMask = 0;
}
internal void ClearReaperCombatRuntime(bool sync)
{
Array.Clear(reaperEnergy, 0, reaperEnergy.Length);
Array.Clear(reaperUltimateCooldowns, 0, reaperUltimateCooldowns.Length);
ReaperSpecialCooldown = 0;
ReaperSpecialCooldownMax = 0;
ReaperExhaustionCooldown = 0;
deathCycleMask = 0;
queuedReaperForm = null;
pendingReaperDomainHarvests.Clear();
Player.GetModPlayer<ReaperCombatPlayer>().ClearRuntime();
if (sync)
SyncReaperCombat();
}
internal void ResetReaperTreeForDebug()
{
ReaperProgression.ResetTreeForDebug();
VisibleReaperForm = ReaperFormId.Base;
VisibleReaperStage = ReaperStage.StageI;
ClearReaperCombatRuntime(sync: true);
if (Main.netMode == NetmodeID.Server)
SoulHarvest.SendReaperProgressionTransaction(Player, Array.Empty<int>());
}
internal void WriteReaperProgression(BinaryWriter writer) => ReaperProgression.NetSend(writer);
internal void WriteDeathDomainProgression(BinaryWriter writer) => DeathDomainProgression.NetSend(writer);
internal void ReceiveDeathDomainProgression(BinaryReader reader) => DeathDomainProgression.NetReceive(reader);
internal void ReceiveReaperProgression(BinaryReader reader)
{
ReaperProgression.NetReceive(reader);
VisibleReaperForm = ReaperCombatRegistry.ResolveUsableForm(ReaperProgression);
VisibleReaperStage = ReaperProgression.GetStage(VisibleReaperForm);
}
internal void ReceiveReaperPublicState(ReaperFormId form, ReaperStage stage)
{
VisibleReaperForm = Enum.IsDefined(form) ? form : ReaperFormId.Base;
VisibleReaperStage = stage is >= ReaperStage.Locked and <= ReaperStage.StageIII ? stage : ReaperStage.Locked;
}
internal void WriteReaperCombat(BinaryWriter writer)
{
writer.Write((byte)ReaperProgression.CurrentForm);
for (int index = 0; index < reaperEnergy.Length; index++)
writer.Write((ushort)Math.Clamp((int)Math.Round(reaperEnergy[index] * 10f), 0, 1000));
writer.Write((ushort)Math.Clamp(ReaperSpecialCooldown, 0, ushort.MaxValue));
writer.Write((ushort)Math.Clamp(ReaperSpecialCooldownMax, 0, ushort.MaxValue));
writer.Write((ushort)Math.Clamp(ReaperExhaustionCooldown, 0, ushort.MaxValue));
for (int index = 0; index < reaperUltimateCooldowns.Length; index++)
writer.Write((ushort)Math.Clamp(reaperUltimateCooldowns[index], 0, ushort.MaxValue));
writer.Write(deathCycleMask);
Player.GetModPlayer<ReaperCombatPlayer>().WriteOwnerRuntime(writer);
}
internal void ReceiveReaperCombat(BinaryReader reader)
{
ReaperProgression.TrySetCurrentForm((ReaperFormId)reader.ReadByte());
VisibleReaperForm = ReaperCombatRegistry.ResolveUsableForm(ReaperProgression);
VisibleReaperStage = ReaperProgression.GetStage(VisibleReaperForm);
for (int index = 0; index < reaperEnergy.Length; index++)
reaperEnergy[index] = MathHelper.Clamp(reader.ReadUInt16() / 10f, 0f, 100f);
ReaperSpecialCooldown = reader.ReadUInt16();
ReaperSpecialCooldownMax = reader.ReadUInt16();
ReaperExhaustionCooldown = reader.ReadUInt16();
for (int index = 0; index < reaperUltimateCooldowns.Length; index++)
reaperUltimateCooldowns[index] = reader.ReadUInt16();
deathCycleMask = reader.ReadByte();
Player.GetModPlayer<ReaperCombatPlayer>().ReceiveOwnerRuntime(reader);
}
public void SyncReaperProgression() => SoulHarvest.SendReaperProgression(Player);
private void SyncReaperCombat() => SoulHarvest.SendReaperCombat(Player);
internal void SyncReaperCombatRuntime() => SyncReaperCombat();
public override void ProcessTriggers(TriggersSet triggersSet)
{
bool reaperInputBlocked = Main.playerInventory
|| Main.drawingPlayerChat
|| Main.editSign
|| Main.editChest
|| Main.mapFullscreen
|| Player.talkNPC >= 0
|| Player.mouseInterface
|| Main.InGameUI.CurrentState is not null
|| ModContent.GetInstance<DeathAltarUISystem>().IsVisible
|| ReaperFormWheelUISystem.IsWheelOpen;
bool holdingReaperSickle = Player.HeldItem.ModItem is NormalSickle;
bool deathFormSelected = holdingReaperSickle
&& ReaperCombatRegistry.ResolveUsableForm(ReaperProgression)
== ReaperFormId.Death;
bool primaryInputHeld = deathFormSelected && Player.controlUseItem;
if (reaperPrimaryInputWasHeld && !primaryInputHeld)
{
ReaperCombatService.ReleaseDeathPrimaryHold(Player);
if (Main.netMode == NetmodeID.MultiplayerClient)
SoulHarvest.SendReaperPrimaryRelease(Player);
}
reaperPrimaryInputWasHeld = primaryInputHeld;
// Mouse2 is Death's documented input. Keep the configurable keybind, but
// also read the physical button so a stale/missing keybind profile cannot
// silently disable the whole right-click request chain.
bool rawSpecialHeld = Main.mouseRight;
bool specialHeld = SoulHarvest.SpecialAttackKeybind?.Current == true
|| rawSpecialHeld;
bool specialPressed = SoulHarvest.SpecialAttackKeybind?.JustPressed == true
|| rawSpecialHeld && !rawSpecialInputWasHeld;
bool specialReleased = SoulHarvest.SpecialAttackKeybind?.JustReleased == true
|| !specialHeld && rawSpecialInputWasHeld;
// A release must still reach the server if an inventory or another UI was
// opened during the charge. The controller also auto-releases at its cap.
if (holdingReaperSickle && specialReleased)
{
if (Main.netMode == NetmodeID.MultiplayerClient)
{
// Release the replicated owner controller immediately as well as
// notifying the server. A quick tap can end before the server's
// spawn packet arrives; the controller also has an owner-input
// fallback for that late-arrival case.
ReaperActionControllerProjectile.RequestLocalOwnerRelease(Player);
SoulHarvest.SendReaperSpecialRelease(Player);
}
else
ReaperCombatService.TryReleaseSpecial(Player);
}
if (holdingReaperSickle && !reaperInputBlocked && !Player.dead && !Player.CCed && !Player.noItems)
{
if (specialHeld)
Player.controlUseTile = false;
Vector2 cursorOffset = Main.MouseWorld - Player.MountedCenter;
Vector2 aim = cursorOffset.SafeNormalize(Vector2.UnitX * Player.direction);
bool deathInvocationHeld = specialHeld
&& ReaperCombatRegistry.ResolveUsableForm(ReaperProgression) == ReaperFormId.Death;
if (specialPressed)
{
if (Main.netMode == NetmodeID.MultiplayerClient)
SoulHarvest.SendReaperAction(Player, ultimate: false, cursorOffset);
else
ReaperCombatService.TryStartSpecial(Player, cursorOffset);
}
else if (deathInvocationHeld && Main.GameUpdateCount % 5UL == (ulong)(Player.whoAmI % 5))
{
int controllerType = ModContent.ProjectileType<ReaperActionControllerProjectile>();
bool invocationExists = Player.ownedProjectileCounts[controllerType] > 0;
if (Main.netMode == NetmodeID.MultiplayerClient)
{
// A press can reach the server during the last primary-swing
// frame and be rejected legitimately. While Death's invocation
// key remains held, retry the start at a bounded cadence until
// its controller is replicated; afterwards packets only update
// the cursor target.
if (invocationExists)
SoulHarvest.SendReaperSpecialAim(Player, cursorOffset);
else
SoulHarvest.SendReaperAction(Player, ultimate: false, cursorOffset);
}
else if (invocationExists)
{
ReaperCombatService.TryUpdateSpecialTarget(Player, cursorOffset);
}
else
{
ReaperCombatService.TryStartSpecial(Player, cursorOffset);
}
}
if (SoulHarvest.UltimateKeybind?.JustPressed == true)
{
if (Main.netMode == NetmodeID.MultiplayerClient)
SoulHarvest.SendReaperAction(Player, ultimate: true, cursorOffset);
else
ReaperCombatService.TryStartUltimate(Player, cursorOffset);
}
}
rawSpecialInputWasHeld = rawSpecialHeld;
ProcessDeathDomainKeybind();
}
private void ProcessDeathDomainKeybind()
{
ModKeybind? keybind = SoulHarvest.ToggleDeathDomainKeybind;
if (keybind is null)
return;
if (keybind.JustPressed)
{
deathDomainKeyHoldFrames = 0;
deathDomainLongPressHandled = false;
}
if (keybind.Current)
{
deathDomainKeyHoldFrames++;
if (!deathDomainLongPressHandled
&& deathDomainKeyHoldFrames >= DeathDomainLongPressFrames)
{
deathDomainLongPressHandled = true;
TryToggleDeathDomainDescent();
}
return;
}
if (keybind.JustReleased && !deathDomainLongPressHandled)
TryToggleFiniteDeathDomain();
if (keybind.JustReleased || deathDomainKeyHoldFrames > 0)
{
deathDomainKeyHoldFrames = 0;
deathDomainLongPressHandled = false;
}
}
private void TryToggleFiniteDeathDomain()
{
DeathNecklace? necklace = ActiveDeathNecklace ?? FindEquippedDeathNecklace();
if (necklace is null)
return;
bool enabled = !DeathDomainEnabled;
ReceiveDeathDomainState(enabled, descended: false);
SoulHarvest.SendDeathDomainState(Player, DeathDomainEnabled, DeathDomainDescended);
}
private void TryToggleDeathDomainDescent()
{
DeathNecklace? necklace = ActiveDeathNecklace ?? FindEquippedDeathNecklace();
if (necklace is null)
return;
if (!necklace.DeathDescentUnlocked)
return;
bool descended = !DeathDomainDescended;
ReceiveDeathDomainState(enabled: true, descended: descended);
SoulHarvest.SendDeathDomainState(Player, DeathDomainEnabled, DeathDomainDescended);
}
internal void SetDeathNecklace(DeathNecklace necklace)
{
if (ActiveDeathNecklace is null || necklace.MasteryScore > ActiveDeathNecklace.MasteryScore)
ActiveDeathNecklace = necklace;
}
private DeathNecklace? FindEquippedDeathNecklace()
{
DeathNecklace? best = null;
foreach (Item item in Player.armor)
{
if (item.ModItem is DeathNecklace candidate && (best is null || candidate.MasteryScore > best.MasteryScore))
best = candidate;
}
return best;
}
internal void MergeLegacyDeathNecklaceProgression(DeathNecklace necklace)
{
bool changed = DeathDomainProgression.MergeLegacyNecklaceLevels(
necklace.LegacyFrequencyLevel,
necklace.LegacyDamageLevel,
necklace.LegacyVolleyLevel,
necklace.LegacyLifeStealLevel);
if (!changed)
return;
if (Main.netMode == NetmodeID.Server)
SyncPlayer(Player.whoAmI, -1, false);
else if (Main.netMode == NetmodeID.MultiplayerClient && Player.whoAmI == Main.myPlayer)
SoulHarvest.SendReaperProgression(Player);
}
internal DeathNecklace? GetEquippedDeathNecklace()
=> ActiveDeathNecklace ?? FindEquippedDeathNecklace();
internal bool IsDeathDomainDescended(DeathNecklace? necklace)
=> DeathDomainEnabled
&& DeathDomainDescended
&& necklace?.DeathDescentUnlocked == true;
internal void SpawnDeathDomainHarvestFromReaperRift(NPC target,
bool requiem, float rotation)
{
if (Main.netMode == NetmodeID.MultiplayerClient || !target.active)
return;
SpawnDeathDomainHarvestSlash(target, DeathDomainProgression.VisualMastery,
requiem, rotation);
}
internal void QueueDeathDomainHarvestFromReaperHit(NPC target,
float rotation, int deathUltimateActionId = -1)
{
if (Main.netMode == NetmodeID.MultiplayerClient || !target.active)
return;
const int telegraphFrames = 12;
float visualMastery = DeathDomainProgression.VisualMastery;
float scale = MathHelper.Clamp(Math.Max(target.width, target.height) / 52f,
0.85f, 2.4f);
deathDomainVolleyCounter++;
bool requiem = IsDeathDomainDescended(GetEquippedDeathNecklace())
&& deathDomainVolleyCounter % 4 == 0;
if (Main.netMode == NetmodeID.Server)
{
SoulHarvest.BroadcastDeathDomainTelegraph(Player, target,
telegraphFrames, DeathDomainProgression.SlashCount, visualMastery, rotation,
scale);
}
else
{
int projectileIndex = Projectile.NewProjectile(
Player.GetSource_Misc("SoulHarvest:ReaperNecklaceTelegraph"),
target.Center,
Vector2.Zero,
ModContent.ProjectileType<DeathDomainHarvestTelegraphProjectile>(),
0,
0f,
Player.whoAmI,
target.whoAmI,
telegraphFrames,
DeathDomainProgression.SlashCount + visualMastery * 0.1f);
if (projectileIndex >= 0 && projectileIndex < Main.maxProjectiles)
{
Projectile telegraph = Main.projectile[projectileIndex];
telegraph.rotation = rotation;
telegraph.scale = scale;
}
}
pendingReaperDomainHarvests.Add(new PendingReaperDomainHarvest(
target.whoAmI,
telegraphFrames,
rotation,
visualMastery,
requiem,
deathUltimateActionId));
}
#if DEBUG
internal bool HasPendingReaperDomainHarvest(int npcIndex)
=> pendingReaperDomainHarvests.Exists(pending
=> pending.NpcIndex == npcIndex);
internal bool HasPendingDeathDomainStrike(int npcIndex)
=> pendingDeathDomainStrikes.Exists(pending
=> pending.NpcIndex == npcIndex);
#endif
private void UpdatePendingReaperDomainHarvests()
{
if (Main.netMode == NetmodeID.MultiplayerClient
|| pendingReaperDomainHarvests.Count == 0)
{
return;
}
for (int index = pendingReaperDomainHarvests.Count - 1;
index >= 0; index--)
{
PendingReaperDomainHarvest pending =
pendingReaperDomainHarvests[index];
int remainingFrames = pending.RemainingFrames - 1;
if (remainingFrames > 0)
{
pendingReaperDomainHarvests[index] = pending with
{
RemainingFrames = remainingFrames
};
continue;
}
pendingReaperDomainHarvests.RemoveAt(index);
if (pending.NpcIndex < 0 || pending.NpcIndex >= Main.maxNPCs)
continue;
NPC target = Main.npc[pending.NpcIndex];
if (!ReaperTargeting.IsValidWeaponTarget(target))
{
continue;
}
SpawnDeathDomainHarvestSlash(target, pending.VisualMastery,
pending.Requiem, pending.Rotation,
pending.DeathUltimateActionId);
}
}
private void UpdateDeathDomain()
{
// UpdateAccessory normally supplies this reference. The direct equipment scan
// keeps dedicated-server attacks stable on ticks where accessory recalculation
// did not expose the ModItem instance before PostUpdate.
DeathNecklace? necklace = ActiveDeathNecklace ?? FindEquippedDeathNecklace();
if (DeathDomainEnabled && necklace is null)
{
ReceiveDeathDomainState(enabled: false, descended: false);
if (Main.netMode == NetmodeID.Server)
SoulHarvest.BroadcastDeathDomainState(Player.whoAmI, enabled: false, descended: false);
else
SoulHarvest.SendDeathDomainState(Player, enabled: false, descended: false);
}
if (!DeathDomainEnabled || necklace is null || Player.dead)
{
deathDomainTimer = 0;
ClearDeathDomainTelegraphs();
return;
}
// Percentage damage is authoritative. Clients receive synchronized NPC strikes
// and the dedicated visual projectile spawned by the server.
if (Main.netMode == NetmodeID.MultiplayerClient)
return;
bool descended = IsDeathDomainDescended(necklace);
ApplyDeathDomainForces(necklace, descended);
ApplyDeathDomainItemAttraction();
List<NPC> targets = FindDeathDomainTargets(necklace, descended);
float visualMastery = DeathDomainProgression.VisualMastery;
UpdateDeathDomainTelegraphs(targets, visualMastery);
deathDomainTimer++;
if (deathDomainTimer < DeathDomainProgression.SpawnInterval)
return;
deathDomainTimer = 0;
if (targets.Count == 0)
{
ClearDeathDomainTelegraphs();
return;
}
deathDomainVolleyCounter++;
bool requiem = descended && deathDomainVolleyCounter % 4 == 0;
foreach (NPC target in targets)
{
float rotation = deathDomainPendingCuts.TryGetValue(target.whoAmI, out PendingDeathDomainCut pending)
? pending.Rotation
: Main.rand.NextFloat(MathHelper.TwoPi);
SpawnDeathDomainHarvestSlash(target, visualMastery, requiem, rotation);
}
ClearDeathDomainTelegraphs();
}
private void UpdateDeathDomainTelegraphs(List<NPC> targets, float visualMastery)
{
HashSet<int> activeTargets = [];
foreach (NPC target in targets)
activeTargets.Add(target.whoAmI);
List<int> staleTargets = [];
foreach (int npcIndex in deathDomainPendingCuts.Keys)
if (!activeTargets.Contains(npcIndex))
staleTargets.Add(npcIndex);
foreach (int npcIndex in staleTargets)
RemoveDeathDomainTelegraph(npcIndex);
int remainingFrames = Math.Max(1, DeathDomainProgression.SpawnInterval - deathDomainTimer);
foreach (NPC target in targets)
{
if (deathDomainPendingCuts.ContainsKey(target.whoAmI))
continue;
float rotation = Main.rand.NextFloat(MathHelper.TwoPi);
float scale = MathHelper.Clamp(Math.Max(target.width, target.height) / 52f, 0.85f, 2.4f);
int projectileIndex = -1;
if (Main.netMode == NetmodeID.Server)
{
SoulHarvest.BroadcastDeathDomainTelegraph(
Player,
target,
remainingFrames,
DeathDomainProgression.SlashCount,
visualMastery,
rotation,
scale);
}
else
{
projectileIndex = Projectile.NewProjectile(
Player.GetSource_Misc("SoulHarvest:DeathDomainTelegraph"),
target.Center,
Vector2.Zero,
ModContent.ProjectileType<DeathDomainHarvestTelegraphProjectile>(),
0,
0f,
Player.whoAmI,
target.whoAmI,
remainingFrames,
DeathDomainProgression.SlashCount + visualMastery * 0.1f);
if (projectileIndex >= 0 && projectileIndex < Main.maxProjectiles)
{
Projectile telegraph = Main.projectile[projectileIndex];
telegraph.rotation = rotation;
telegraph.scale = scale;
}
}
deathDomainPendingCuts[target.whoAmI] = new PendingDeathDomainCut(rotation, projectileIndex);
}
}
private void SpawnDeathDomainHarvestSlash(NPC target, float visualMastery,
bool requiem, float rotation, int deathUltimateActionId = -1)
{
int slashDamage = CalculateDeathDomainSlashDamage(target, DeathDomainProgression);
float scale = MathHelper.Clamp(Math.Max(target.width, target.height) / 52f, 0.85f, 2.4f);
float visualState = visualMastery + (requiem ? 2f : 0f);
if (Main.netMode == NetmodeID.Server)
{
for (int slash = 0; slash < DeathDomainProgression.SlashCount; slash++)
{
pendingDeathDomainStrikes.Add(new PendingDeathDomainStrike(
target.whoAmI,
DeathDomainHarvestSlashProjectile.SlashImpactFrame
+ slash * DeathDomainHarvestSlashProjectile.SlashDelayFrames,
slashDamage,
DeathDomainProgression.LifeStealLevel,
deathUltimateActionId));
}
SoulHarvest.BroadcastDeathDomainHarvestSlash(
Player,
target,
DeathDomainProgression.SlashCount,
visualMastery,
requiem,
rotation,
scale);
return;
}
int projectileIndex = Projectile.NewProjectile(
Player.GetSource_Misc("SoulHarvest:DeathDomain"),
target.Center,
Vector2.Zero,
ModContent.ProjectileType<DeathDomainHarvestSlashProjectile>(),
slashDamage,
0f,
Player.whoAmI,
target.whoAmI,
DeathDomainProgression.SlashCount,
visualState);
if (projectileIndex < 0 || projectileIndex >= Main.maxProjectiles)
return;
Projectile visual = Main.projectile[projectileIndex];
visual.rotation = rotation;
visual.scale = scale;
visual.localAI[1] = DeathDomainProgression.LifeStealLevel;
if (visual.ModProjectile is DeathDomainHarvestSlashProjectile harvest)
harvest.SetDeathUltimateActionId(deathUltimateActionId);
}
private void UpdatePendingDeathDomainStrikes()
{
if (Main.netMode == NetmodeID.MultiplayerClient || pendingDeathDomainStrikes.Count == 0)
return;
for (int index = pendingDeathDomainStrikes.Count - 1; index >= 0; index--)
{
PendingDeathDomainStrike pending = pendingDeathDomainStrikes[index];
int remainingFrames = pending.RemainingFrames - 1;
if (remainingFrames > 0)
{
pendingDeathDomainStrikes[index] = pending with { RemainingFrames = remainingFrames };
continue;
}
pendingDeathDomainStrikes.RemoveAt(index);
ApplyDeathDomainStrike(pending.NpcIndex, pending.Damage,
pending.LifeStealLevel, pending.DeathUltimateActionId);
}
}
internal void ApplyDeathDomainStrike(int npcIndex, int damage,
int lifeStealLevel, int deathUltimateActionId = -1)
{
if (npcIndex < 0 || npcIndex >= Main.maxNPCs)
return;
NPC target = Main.npc[npcIndex];
if (!ReaperTargeting.IsValidWeaponTarget(target))
return;
bool bossOrBossPart = target.boss
|| NPCID.Sets.ShouldBeCountedAsBoss[target.type]
|| target.realLife >= 0 && target.realLife < Main.maxNPCs && Main.npc[target.realLife].boss;
target.GetGlobalNPC<MyGlobalNPC>().ApplyDeathDomainHitStop(target, bossOrBossPart ? 1 : 3);
if (Main.netMode == NetmodeID.MultiplayerClient || damage <= 0)
return;
target.GetGlobalNPC<MyGlobalNPC>().RegisterSickleHit(target, Player.whoAmI);
target.lastInteraction = Player.whoAmI;
NPC.HitInfo harvestHit = new()
{
Damage = damage,
SourceDamage = damage,
HitDirection = 0,
Knockback = 0f,
DamageType = DamageClass.Generic,
Crit = false
};
int lifeBeforeHit = target.life;
Vector2 lifeStealSource = target.Center;
NPC accumulationTarget = target.realLife >= 0
&& target.realLife < Main.maxNPCs
&& Main.npc[target.realLife].active
? Main.npc[target.realLife]
: target;
target.StrikeNPC(harvestHit, fromNet: false, noPlayerInteraction: false);
int actualDamage = Math.Max(0, lifeBeforeHit - Math.Max(0, target.life));
if (deathUltimateActionId >= 0 && actualDamage > 0)
{
accumulationTarget.GetGlobalNPC<ReaperCombatGlobalNPC>()
.RecordDeathUltimateCut(accumulationTarget, Player.whoAmI,
deathUltimateActionId, actualDamage);
}
ApplyDeathReaperLifeSteal(actualDamage, lifeStealSource, lifeStealLevel);
if (Main.netMode == NetmodeID.Server)
{
NetMessage.SendStrikeNPC(target, in harvestHit);
target.netUpdate = true;
}
}
private void ClearDeathDomainTelegraphs()
{
if (deathDomainPendingCuts.Count == 0)
return;
foreach ((int npcIndex, PendingDeathDomainCut pending) in deathDomainPendingCuts)
{
KillDeathDomainTelegraph(pending.ProjectileIndex);
SoulHarvest.BroadcastDeathDomainTelegraphClear(Player, npcIndex);
}
deathDomainPendingCuts.Clear();
}
private void RemoveDeathDomainTelegraph(int npcIndex)
{
if (!deathDomainPendingCuts.Remove(npcIndex, out PendingDeathDomainCut pending))
return;
KillDeathDomainTelegraph(pending.ProjectileIndex);
SoulHarvest.BroadcastDeathDomainTelegraphClear(Player, npcIndex);
}
private void KillDeathDomainTelegraph(int projectileIndex)
{
if (projectileIndex < 0 || projectileIndex >= Main.maxProjectiles)
return;
Projectile projectile = Main.projectile[projectileIndex];
if (projectile.active
&& projectile.owner == Player.whoAmI
&& projectile.ModProjectile is DeathDomainHarvestTelegraphProjectile)
{
projectile.Kill();
}
}
private List<NPC> FindDeathDomainTargets(DeathNecklace necklace, bool descended)
{
List<NPC> targets = [];
HashSet<int> selectedTargets = [];
bool harvestEntireWorld = descended;
float radiusSquared = necklace.DomainRadius * necklace.DomainRadius;
foreach (NPC npc in Main.ActiveNPCs)
{
// chaseable is intentionally not used: vanilla NPCs toggle it during
// movement and transition states, which made dedicated-server harvests
// appear to skip otherwise valid enemies from one cycle to the next.
// Training dummies are excluded from the passive necklace domain even
// though weapon-created Death cuts can explicitly strike them.
if (ReaperTargeting.IsTrainingDummy(npc) || npc.friendly || npc.immortal
|| npc.dontTakeDamage || npc.lifeMax <= 5 || npc.life <= 0)
continue;
NPC target = npc.realLife >= 0 && npc.realLife < Main.maxNPCs && Main.npc[npc.realLife].active
? Main.npc[npc.realLife]
: npc;
if (ReaperTargeting.IsTrainingDummy(target) || target.friendly
|| target.immortal || target.dontTakeDamage || target.lifeMax <= 5 || target.life <= 0
|| selectedTargets.Contains(target.whoAmI)
|| !harvestEntireWorld && Vector2.DistanceSquared(Player.Center, target.Center) > radiusSquared)
{
continue;
}
selectedTargets.Add(target.whoAmI);
targets.Add(target);
}
return targets;
}
private void ApplyDeathDomainForces(DeathNecklace necklace, bool descended)
{
float innerRadius = descended ? 3200f : necklace.DomainRadius;
float outerRadius = descended ? innerRadius : necklace.DomainAttractionRadius;
float innerRadiusSquared = innerRadius * innerRadius;
float outerRadiusSquared = outerRadius * outerRadius;
float attractionWidth = Math.Max(1f, outerRadius - innerRadius);
HashSet<int> affectedTargets = [];
foreach (NPC npc in Main.ActiveNPCs)
{
if (ReaperTargeting.IsTrainingDummy(npc) || npc.friendly || npc.immortal
|| npc.dontTakeDamage || npc.lifeMax <= 5 || npc.life <= 0)
continue;
NPC target = npc.realLife >= 0 && npc.realLife < Main.maxNPCs && Main.npc[npc.realLife].active
? Main.npc[npc.realLife]
: npc;
if (ReaperTargeting.IsTrainingDummy(target) || target.friendly
|| target.immortal || target.dontTakeDamage || target.lifeMax <= 5 || target.life <= 0
|| !affectedTargets.Add(target.whoAmI))
{
continue;
}
Vector2 toDomain = Player.Center - target.Center;
float distanceSquared = toDomain.LengthSquared();
if (distanceSquared > outerRadiusSquared)
continue;
bool bossOrBossPart = target.boss
|| NPCID.Sets.ShouldBeCountedAsBoss[target.type]
|| target.realLife >= 0 && target.realLife < Main.maxNPCs && Main.npc[target.realLife].boss;
float bossScale = bossOrBossPart ? 0.18f : 1f;
float distance = (float)Math.Sqrt(distanceSquared);
bool forceApplied;
if (distanceSquared <= innerRadiusSquared)
{
// Inside the event horizon the flow reverses: enemies are expelled
// from the pale-crimson core. The force reaches zero at the border
// and rises quadratically toward the centre.
Vector2 outward = distance > 0.001f
? -toDomain / distance
: (target.whoAmI * 2.39996323f).ToRotationVector2();
float centralInfluence = 1f - MathHelper.Clamp(distance / innerRadius, 0f, 1f);
centralInfluence *= centralInfluence;
float force = necklace.DomainRepulsionStrength * centralInfluence * bossScale;
float maximumOutwardSpeed = necklace.DomainRepulsionMaximumSpeed * (bossOrBossPart ? 0.35f : 1f);
float currentOutwardSpeed = Vector2.Dot(target.velocity, outward);
float allowedForce = Math.Max(0f, maximumOutwardSpeed - currentOutwardSpeed);
float appliedForce = Math.Min(force, allowedForce);
target.velocity += outward * appliedForce;
forceApplied = appliedForce > 0f;
}
else
{
// Outside the border the black-hole streams draw enemies inward.
float normalizedDistance = MathHelper.Clamp((distance - innerRadius) / attractionWidth, 0f, 1f);
float edgeInfluence = 1f - normalizedDistance;
edgeInfluence = edgeInfluence * edgeInfluence * (3f - 2f * edgeInfluence);
float force = necklace.DomainAttractionStrength * MathHelper.Lerp(0.22f, 1f, edgeInfluence) * bossScale;
float maximumInwardSpeed = necklace.DomainAttractionMaximumSpeed * (bossOrBossPart ? 0.35f : 1f);
Vector2 inward = toDomain / distance;
float currentInwardSpeed = Vector2.Dot(target.velocity, inward);
float allowedForce = Math.Max(0f, maximumInwardSpeed - currentInwardSpeed);
float appliedForce = Math.Min(force, allowedForce);
target.velocity += inward * appliedForce;
forceApplied = appliedForce > 0f;
}
// Periodic authoritative velocity updates are enough for this gentle
// force while avoiding an NPC sync packet on every tick.
if (forceApplied && (Main.GameUpdateCount + (ulong)target.whoAmI) % 10UL == 0UL)
target.netUpdate = true;
}
}
private void ApplyDeathDomainItemAttraction()
{
// The domain owns the whole world while active. Move eligible drops in
// world space on the authority instead of relying on vanilla pickup range;
// this also lets coins and loot travel through terrain rather than becoming
// trapped against a distant wall. Eligible drops are reserved to the
// domain owner while the attraction is active.
Vector2 destination = Player.MountedCenter;
for (int itemIndex = 0; itemIndex < Main.maxItems; itemIndex++)
{
Item item = Main.item[itemIndex];
if (!item.active || item.IsAir || item.stack <= 0
|| !ItemLoader.CanPickup(item, Player)
|| !Player.CanPullItem(item, Player.ItemSpace(item)))
{
continue;
}
// Item.Update continuously chooses a reservation owner. Without
// claiming the drop here, that vanilla pass can immediately hand a
// full-world attraction target to another nearby player and undo the
// domain movement before its owner can collect it.
item.playerIndexTheItemIsReservedFor = Player.whoAmI;
Vector2 toPlayer = destination - item.Center;
float distance = toPlayer.Length();
if (distance <= 1f)
continue;
Vector2 direction = toPlayer / distance;
float step = Math.Min(distance,
MathHelper.Clamp(distance * 0.085f, 18f, 180f));
item.position += direction * step;
item.velocity = direction * Math.Min(24f, 8f + distance * 0.004f);
item.noGrabDelay = 0;
if (distance < 54f)
{
item.Center = destination;
item.velocity = Vector2.Zero;
}
if (Main.netMode == NetmodeID.Server
&& (Main.GameUpdateCount + (ulong)itemIndex) % 6UL == 0UL)
{
NetMessage.SendData(MessageID.SyncItem, -1, -1, null, itemIndex);
}
}
}
private static int CalculateDeathDomainSlashDamage(NPC target, DeathDomainProgression progression)
{
bool bossOrBossPart = target.boss
|| NPCID.Sets.ShouldBeCountedAsBoss[target.type]
|| target.realLife >= 0 && target.realLife < Main.maxNPCs && Main.npc[target.realLife].boss;
float lifeRatio = bossOrBossPart ? progression.BossSlashLifeRatio : progression.NormalSlashLifeRatio;
return Math.Max(1, (int)Math.Ceiling(target.lifeMax * lifeRatio));
}
private void SyncSoulBalance()
{
if (Main.netMode != NetmodeID.Server
|| Player.whoAmI < 0
|| Player.whoAmI >= Netplay.Clients.Length
|| !Netplay.Clients[Player.whoAmI].IsActive)
{
return;
}
SyncPlayer(Player.whoAmI, -1, false);
}
}
using SoulHarvest.Items;
using SoulHarvest.Projectiles;
using SoulHarvest.UI;
using Microsoft.Xna.Framework;
using System;
using System.Collections.Generic;
using System.IO;
using Terraria;
using Terraria.Audio;
using Terraria.Chat;
using Terraria.ID;
using Terraria.GameInput;
using Terraria.Localization;
using Terraria.ModLoader;
using Terraria.ModLoader.IO;
namespace SoulHarvest.Common;
public class MyPlayer : ModPlayer
{
public const int StartingSouls = 150;
public const int SoulsPerEssence = 100;
internal const int DeathWingTrailCapacity = 28;
private const int DeathWingBodyRiftIdentity = -173870001;
private const int DeathWingDomainChunkCapacity = 14;
private const int DeathWingDomainChunkOverlapPoints = 4;
public int Souls { get; private set; } = StartingSouls;
public int SickleAlternateCooldown { get; private set; }
public int SickleAlternateCooldownMax { get; private set; }
public bool DeathDomainEnabled { get; private set; }
public bool DeathDomainDescended { get; private set; }
public DeathDomainProgression DeathDomainProgression { get; } = new();
public ReaperProgressionState ReaperProgression { get; } = new();
public int ReaperSpecialCooldown { get; private set; }
public int ReaperSpecialCooldownMax { get; private set; }
public int ReaperExhaustionCooldown { get; private set; }
public ReaperFormId VisibleReaperForm { get; private set; } = ReaperFormId.Base;
public ReaperStage VisibleReaperStage { get; private set; } = ReaperStage.StageI;
internal DeathNecklace? ActiveDeathNecklace { get; private set; }
internal bool DeathWingsActiveThisTick { get; set; }
internal bool DeathWingsActiveLastTick { get; private set; }
internal Vector2[] DeathWingUpperTrail { get; } = new Vector2[DeathWingTrailCapacity];
internal Vector2[] DeathWingLowerTrail { get; } = new Vector2[DeathWingTrailCapacity];
internal Vector2[] DeathWingBodyTrail { get; } = new Vector2[DeathWingTrailCapacity];
internal int DeathWingTrailCount { get; private set; }
internal float DeathWingTrailMastery { get; private set; }
internal float DeathWingTrailOpacity { get; private set; }
internal float DeathWingRiftStrength { get; private set; }
private int lifeStealCooldown;
private int swingComboDirection = 1;
private bool reaperPrimaryInputWasHeld;
private bool rawSpecialInputWasHeld;
private bool serverSoulBalanceInitialized;
private bool serverReaperProgressionInitialized;
private int clientCharacterStateUploadDelay;
private int clientCharacterStateUploadAttempts;
private bool clientCharacterStateAcknowledged;
private bool reaperDeathStateCleared;
private bool reaperLegacyMigrationChecked;
private int reaperLegacyMigrationVersion;
private int pendingLegacyMigrationEssence;
private TagCompound? reaperLegacyMigrationSnapshot;
private int reaperLegacyMigrationNoticeWeapons;
private int reaperLegacyMigrationNoticeRefund;
private readonly float[] reaperEnergy = new float[8];
private readonly int[] reaperUltimateCooldowns = new int[8];
private byte deathCycleMask;
private ReaperFormId? queuedReaperForm;
private ulong lastReaperFormSwitchRequestTick;
private ulong deathWingTrailUpdateTick = ulong.MaxValue;
private ulong deathWingDomainCombatUpdateTick = ulong.MaxValue;
private readonly List<Vector2> deathWingDomainChunkPoints = [];
private readonly List<float> deathWingDomainChunkOpacities = [];
private readonly ulong[] deathWingDomainNextHarvestTicks =
new ulong[Main.maxNPCs];
private float deathWingDomainChunkMastery;
private bool deathWingDomainChunkHasNewGeometry;
private int deathWingDomainChunkSequence;
private int deathWingDomainVolleyCounter;
private int deathDomainTimer;
private int deathDomainVolleyCounter;
private int deathDomainKeyHoldFrames;
private bool deathDomainLongPressHandled;
private int deathDomainScreenShakeFrames;
private int deathDomainSlashSoundCooldown;
private float deathDomainScreenShakeStrength;
private Vector2 deathDomainScreenShakeDirection = Vector2.UnitY;
private readonly Dictionary<int, PendingDeathDomainCut> deathDomainPendingCuts = [];
private readonly List<PendingDeathDomainStrike> pendingDeathDomainStrikes = [];
private readonly List<PendingReaperDomainHarvest> pendingReaperDomainHarvests = [];
private const int DeathDomainLongPressFrames = 45;
private readonly record struct PendingDeathDomainCut(float Rotation, int ProjectileIndex);
private readonly record struct PendingDeathDomainStrike(
int NpcIndex,
int RemainingFrames,
int Damage,
int LifeStealLevel,
int DeathUltimateActionId);
private readonly record struct PendingReaperDomainHarvest(
int NpcIndex,
int RemainingFrames,
float Rotation,
float VisualMastery,
bool Requiem,
int DeathUltimateActionId);
public override void Initialize()
{
Souls = StartingSouls;
lifeStealCooldown = 0;
swingComboDirection = 1;
reaperPrimaryInputWasHeld = false;
rawSpecialInputWasHeld = false;
SickleAlternateCooldown = 0;
SickleAlternateCooldownMax = 0;
DeathWingsActiveThisTick = false;
DeathWingsActiveLastTick = false;
ClearDeathWingTrail();
DiscardDeathWingDomainChunk();
deathWingDomainCombatUpdateTick = ulong.MaxValue;
Array.Clear(deathWingDomainNextHarvestTicks);
deathWingDomainChunkSequence = 0;
deathWingDomainVolleyCounter = 0;
serverSoulBalanceInitialized = false;
serverReaperProgressionInitialized = false;
clientCharacterStateUploadDelay = 0;
clientCharacterStateUploadAttempts = 0;
clientCharacterStateAcknowledged = false;
DeathDomainProgression.Reset();
ReaperProgression.Reset();
VisibleReaperForm = ReaperFormId.Base;
VisibleReaperStage = ReaperStage.StageI;
ClearReaperCombatRuntime(sync: false);
reaperDeathStateCleared = false;
queuedReaperForm = null;
lastReaperFormSwitchRequestTick = 0;
reaperLegacyMigrationChecked = false;
reaperLegacyMigrationVersion = 0;
pendingLegacyMigrationEssence = 0;
reaperLegacyMigrationSnapshot = null;
reaperLegacyMigrationNoticeWeapons = 0;
reaperLegacyMigrationNoticeRefund = 0;
DeathDomainEnabled = false;
DeathDomainDescended = false;
ActiveDeathNecklace = null;
deathDomainTimer = 0;
deathDomainVolleyCounter = 0;
deathDomainKeyHoldFrames = 0;
deathDomainLongPressHandled = false;
deathDomainScreenShakeFrames = 0;
deathDomainSlashSoundCooldown = 0;
deathDomainScreenShakeStrength = 0f;
deathDomainScreenShakeDirection = Vector2.UnitY;
deathDomainPendingCuts.Clear();
pendingDeathDomainStrikes.Clear();
pendingReaperDomainHarvests.Clear();
}
public override void ResetEffects()
{
DeathWingsActiveThisTick = false;
ActiveDeathNecklace = null;
}
public override void SaveData(TagCompound tag)
{
tag[nameof(Souls)] = Souls;
tag[nameof(DeathDomainEnabled)] = DeathDomainEnabled;
tag[nameof(DeathDomainDescended)] = DeathDomainDescended;
DeathDomainProgression.SaveData(tag);
ReaperProgression.SaveData(tag);
tag["ReaperLegacyMigrationVersion"] = reaperLegacyMigrationVersion;
tag["PendingLegacyMigrationEssence"] = pendingLegacyMigrationEssence;
if (reaperLegacyMigrationSnapshot is not null)
tag["ReaperLegacyMigrationSnapshot"] = reaperLegacyMigrationSnapshot;
}
public override void LoadData(TagCompound tag)
{
Souls = tag.ContainsKey(nameof(Souls)) ? Math.Max(0, tag.GetInt(nameof(Souls))) : StartingSouls;
DeathDomainEnabled = tag.GetBool(nameof(DeathDomainEnabled));
DeathDomainDescended = DeathDomainEnabled && tag.GetBool(nameof(DeathDomainDescended));
DeathDomainProgression.LoadData(tag);
ReaperProgression.LoadData(tag);
reaperLegacyMigrationVersion = tag.ContainsKey("ReaperLegacyMigrationVersion")
? Math.Max(0, tag.GetInt("ReaperLegacyMigrationVersion"))
: 0;
pendingLegacyMigrationEssence = Math.Max(0, tag.GetInt("PendingLegacyMigrationEssence"));
reaperLegacyMigrationSnapshot = tag.ContainsKey("ReaperLegacyMigrationSnapshot")
? tag.GetCompound("ReaperLegacyMigrationSnapshot")
: null;
// Player inventory and personal banks have already been deserialized when
// ModPlayer data is loaded. Migrating here guarantees the authoritative
// tree is ready before the first multiplayer SyncPlayer upload.
if (reaperLegacyMigrationVersion < ReaperLegacyMigration.MigrationVersion)
{
ReaperLegacyMigrationResult result = ReaperLegacyMigration.Apply(Player, ReaperProgression);
reaperLegacyMigrationVersion = ReaperLegacyMigration.MigrationVersion;
pendingLegacyMigrationEssence = Math.Max(0, pendingLegacyMigrationEssence + result.RefundEssence);
reaperLegacyMigrationSnapshot = result.Snapshot;
reaperLegacyMigrationNoticeWeapons = result.ConvertedWeaponCount;
reaperLegacyMigrationNoticeRefund = result.RefundEssence;
TryDeliverPendingLegacyMigrationRefund();
}
}
public override void OnEnterWorld()
{
ReaperCombatService.ResetPlayerState(Player.whoAmI);
ClearReaperCombatRuntime(sync: false);
if (Main.netMode == NetmodeID.MultiplayerClient
&& Player.whoAmI == Main.myPlayer)
{
// ModPlayer.SyncPlayer is not guaranteed to be invoked client-side
// after every join/reload. Explicitly retry the character-owned
// progression upload so the server cannot remain permanently in
// its uninitialized state and reject combat, forms and the domain.
clientCharacterStateUploadDelay = 1;
clientCharacterStateUploadAttempts = 0;
clientCharacterStateAcknowledged = false;
}
if (Main.netMode == NetmodeID.Server || Player.whoAmI != Main.myPlayer || reaperLegacyMigrationChecked)
return;
reaperLegacyMigrationChecked = true;
TryDeliverPendingLegacyMigrationRefund();
if (reaperLegacyMigrationNoticeWeapons > 0)
{
Main.NewText(
Language.GetTextValue(
"Mods.SoulHarvest.Messages.ReaperMigrationComplete",
reaperLegacyMigrationNoticeWeapons,
reaperLegacyMigrationNoticeRefund),
new Color(205, 90, 235));
reaperLegacyMigrationNoticeWeapons = 0;
reaperLegacyMigrationNoticeRefund = 0;
}
}
public override void PostUpdate()
{
UpdateClientCharacterStateUpload();
if (Main.netMode != NetmodeID.Server && Player.whoAmI == Main.myPlayer && pendingLegacyMigrationEssence > 0)
TryDeliverPendingLegacyMigrationRefund();
if (lifeStealCooldown > 0)
lifeStealCooldown--;
if (SickleAlternateCooldown > 0)
SickleAlternateCooldown--;
if (ReaperSpecialCooldown > 0)
ReaperSpecialCooldown--;
if (ReaperExhaustionCooldown > 0)
ReaperExhaustionCooldown--;
for (int formIndex = 0; formIndex < reaperUltimateCooldowns.Length; formIndex++)
{
if (reaperUltimateCooldowns[formIndex] > 0)
reaperUltimateCooldowns[formIndex]--;
}
TryApplyQueuedReaperForm();
if (deathDomainSlashSoundCooldown > 0)
deathDomainSlashSoundCooldown--;
UpdatePendingReaperDomainHarvests();
UpdatePendingDeathDomainStrikes();
UpdateDeathDomain();
if (Player.dead)
{
if (!reaperDeathStateCleared)
{
ClearReaperCombatRuntime(sync: true);
reaperDeathStateCleared = true;
}
ClearDeathWingTrail();
DiscardDeathWingDomainChunk();
}
else if (!DeathWingsActiveThisTick && DeathWingTrailOpacity > 0f)
{
DeathWingTrailOpacity *= 0.78f;
if (DeathWingTrailOpacity < 0.025f)
ClearDeathWingTrail();
}
if (!Player.dead && DeathWingTrailOpacity > 0f)
SubmitDeathWingDomainRifts();
if (!Player.dead && !DeathWingsActiveThisTick
&& Main.netMode != NetmodeID.MultiplayerClient)
{
SpawnDeathWingDomainChunk(keepTail: false);
}
if (!Player.dead)
reaperDeathStateCleared = false;
DeathWingsActiveLastTick = DeathWingsActiveThisTick;
}
private void UpdateClientCharacterStateUpload()
{
if (Main.netMode != NetmodeID.MultiplayerClient
|| Player.whoAmI != Main.myPlayer
|| clientCharacterStateAcknowledged)
{
return;
}
if (clientCharacterStateUploadDelay > 0)
{
clientCharacterStateUploadDelay--;
if (clientCharacterStateUploadDelay > 0)
return;
}
SoulHarvest.SendReaperProgression(Player);
clientCharacterStateUploadAttempts++;
clientCharacterStateUploadDelay = clientCharacterStateUploadAttempts switch
{
1 => 30,
2 => 90,
3 => 180,
_ => 300
};
}
internal void AcknowledgeServerCharacterState()
{
if (Main.netMode == NetmodeID.MultiplayerClient
&& Player.whoAmI == Main.myPlayer)
{
clientCharacterStateAcknowledged = true;
clientCharacterStateUploadDelay = 0;
}
}
#if DEBUG
internal bool ClientCharacterStateAcknowledged
=> clientCharacterStateAcknowledged;
#endif
private void TryDeliverPendingLegacyMigrationRefund()
{
if (pendingLegacyMigrationEssence <= 0)
return;
int essenceType = ModContent.ItemType<Soul>();
int slotCount = Math.Min(58, Player.inventory.Length);
for (int slot = 0; slot < slotCount && pendingLegacyMigrationEssence > 0; slot++)
{
Item item = Player.inventory[slot];
if (item.type != essenceType || item.stack <= 0 || item.stack >= item.maxStack)
continue;
int inserted = Math.Min(pendingLegacyMigrationEssence, item.maxStack - item.stack);
item.stack += inserted;
pendingLegacyMigrationEssence -= inserted;
SyncMigrationInventorySlot(slot);
}
for (int slot = 0; slot < slotCount && pendingLegacyMigrationEssence > 0; slot++)
{
Item item = Player.inventory[slot];
if (!item.IsAir)
continue;
item.SetDefaults(essenceType);
int inserted = Math.Min(pendingLegacyMigrationEssence, item.maxStack);
item.stack = inserted;
pendingLegacyMigrationEssence -= inserted;
SyncMigrationInventorySlot(slot);
}
}
private void SyncMigrationInventorySlot(int slot)
{
if (Main.netMode != NetmodeID.MultiplayerClient)
return;
NetMessage.SendData(
MessageID.SyncEquipment,
-1,
-1,
null,
Player.whoAmI,
PlayerItemSlotID.Inventory0 + slot,
Player.inventory[slot].prefix);
}
public override void ModifyScreenPosition()
{
if (deathDomainScreenShakeFrames > 0 && deathDomainScreenShakeStrength > 0.01f)
{
float remaining = deathDomainScreenShakeFrames / 6f;
float phase = (6 - deathDomainScreenShakeFrames) * 2.45f;
Vector2 tangent = deathDomainScreenShakeDirection.RotatedBy(MathHelper.PiOver2);
Main.screenPosition += deathDomainScreenShakeDirection * ((float)Math.Sin(phase) * deathDomainScreenShakeStrength * remaining)
+ tangent * ((float)Math.Sin(phase * 0.63f + 1.1f) * deathDomainScreenShakeStrength * remaining * 0.32f);
deathDomainScreenShakeFrames--;
if (deathDomainScreenShakeFrames <= 0)
deathDomainScreenShakeStrength = 0f;
}
ReaperVfxDirector.ApplyCameraImpulse(ref Main.screenPosition);
}
internal void TriggerDeathDomainImpact(Vector2 worldPosition, Vector2 direction, float strength)
{
if (Main.dedServ || Player.whoAmI != Main.myPlayer)
return;
float distance = Vector2.Distance(Player.Center, worldPosition);
float falloff = 1f - MathHelper.Clamp(distance / 1200f, 0f, 1f);
strength *= falloff * falloff;
if (strength <= deathDomainScreenShakeStrength)
return;
deathDomainScreenShakeStrength = Math.Min(5.2f, strength);
deathDomainScreenShakeDirection = direction.SafeNormalize(Vector2.UnitY);
deathDomainScreenShakeFrames = 6;
}
internal void PlayDeathDomainSlashSound(Vector2 worldPosition, float mastery, bool requiem)
{
if (Main.dedServ
|| Player.whoAmI != Main.myPlayer
|| deathDomainSlashSoundCooldown > 0
|| Vector2.DistanceSquared(Player.Center, worldPosition) > 1600f * 1600f)
{
return;
}
deathDomainSlashSoundCooldown = 4;
SoundEngine.PlaySound(SoundID.Item71, worldPosition);
}
internal void RecordDeathWingTrail(Vector2 upperTip, Vector2 lowerTip,
float mastery)
{
if (Main.dedServ || deathWingTrailUpdateTick == Main.GameUpdateCount)
return;
deathWingTrailUpdateTick = Main.GameUpdateCount;
if (DeathWingTrailCount > 0
&& (Vector2.DistanceSquared(upperTip, DeathWingUpperTrail[0]) > 120f * 120f
|| Vector2.DistanceSquared(lowerTip, DeathWingLowerTrail[0]) > 120f * 120f
|| Vector2.DistanceSquared(Player.Center, DeathWingBodyTrail[0]) > 120f * 120f))
{
ClearDeathWingTrail();
}
int copyCount = Math.Min(DeathWingTrailCount, DeathWingTrailCapacity - 1);
if (copyCount > 0)
{
Array.Copy(DeathWingUpperTrail, 0, DeathWingUpperTrail, 1, copyCount);
Array.Copy(DeathWingLowerTrail, 0, DeathWingLowerTrail, 1, copyCount);
Array.Copy(DeathWingBodyTrail, 0, DeathWingBodyTrail, 1, copyCount);
}
DeathWingUpperTrail[0] = upperTip;
DeathWingLowerTrail[0] = lowerTip;
DeathWingBodyTrail[0] = Player.Center;
DeathWingTrailCount = Math.Min(DeathWingTrailCount + 1, DeathWingTrailCapacity);
DeathWingTrailMastery = MathHelper.Clamp(mastery, 0f, 1f);
DeathWingTrailOpacity = 1f;
float speedProgress = MathHelper.Clamp(
(Player.velocity.Length() - 2.5f) / 15.5f, 0f, 1f);
DeathWingRiftStrength = speedProgress;
}
internal void RecordDeathWingDomainTrail(float mastery)
{
if (Main.netMode == NetmodeID.MultiplayerClient
|| deathWingDomainCombatUpdateTick == Main.GameUpdateCount)
{
return;
}
deathWingDomainCombatUpdateTick = Main.GameUpdateCount;
float speedProgress = MathHelper.Clamp(
(Player.velocity.Length() - 2.5f) / 15.5f, 0f, 1f);
if (speedProgress <= 0.035f)
{
SpawnDeathWingDomainChunk(keepTail: false);
return;
}
float opacity = GetDeathWingRiftOpacity(speedProgress);
Vector2 point = Player.Center;
if (deathWingDomainChunkPoints.Count > 0
&& Vector2.DistanceSquared(point,
deathWingDomainChunkPoints[^1]) > 120f * 120f)
{
SpawnDeathWingDomainChunk(keepTail: false);
}
if (deathWingDomainChunkPoints.Count >= 6)
{
float averageOpacity = 0f;
foreach (float sample in deathWingDomainChunkOpacities)
averageOpacity += sample;
averageOpacity /= deathWingDomainChunkOpacities.Count;
if (Math.Abs(opacity - averageOpacity) >= 0.16f)
SpawnDeathWingDomainChunk(keepTail: true);
}
if (deathWingDomainChunkPoints.Count > 0
&& Vector2.DistanceSquared(point,
deathWingDomainChunkPoints[^1]) < 2.25f * 2.25f)
{
deathWingDomainChunkPoints[^1] = point;
deathWingDomainChunkOpacities[^1] = opacity;
deathWingDomainChunkMastery = Math.Max(
deathWingDomainChunkMastery,
MathHelper.Clamp(mastery, 0f, 1f));
return;
}
deathWingDomainChunkPoints.Add(point);
deathWingDomainChunkOpacities.Add(opacity);
deathWingDomainChunkHasNewGeometry = true;
deathWingDomainChunkMastery = Math.Max(deathWingDomainChunkMastery,
MathHelper.Clamp(mastery, 0f, 1f));
if (deathWingDomainChunkPoints.Count >= DeathWingDomainChunkCapacity)
SpawnDeathWingDomainChunk(keepTail: true);
}
internal bool TrySpawnDeathWingDomainTrailHarvest(NPC target, int seed)
{
if (Main.netMode == NetmodeID.MultiplayerClient
|| !ReaperTargeting.IsValidWeaponTarget(target))
{
return false;
}
int npcIndex = target.whoAmI;
ulong currentTick = Main.GameUpdateCount;
if (deathWingDomainNextHarvestTicks[npcIndex] > currentTick)
return false;
deathWingDomainNextHarvestTicks[npcIndex] = currentTick
+ (ulong)Math.Max(1, DeathDomainProgression.SpawnInterval);
deathWingDomainVolleyCounter++;
uint hash = unchecked((uint)(seed * 16777619
+ npcIndex * 486187739 + deathWingDomainVolleyCounter * 97));
hash ^= hash >> 16;
float rotation = (hash & 0x00FFFFFFu) / 16777215f
* MathHelper.TwoPi;
bool requiem = IsDeathDomainDescended(GetEquippedDeathNecklace())
&& deathWingDomainVolleyCounter % 4 == 0;
SpawnDeathDomainHarvestFromReaperRift(target, requiem, rotation);
return true;
}
private void SpawnDeathWingDomainChunk(bool keepTail)
{
int count = deathWingDomainChunkPoints.Count;
if (count < 2 || !deathWingDomainChunkHasNewGeometry)
{
if (!keepTail)
DiscardDeathWingDomainChunk();
return;
}
int retainedCount = keepTail
? Math.Min(DeathWingDomainChunkOverlapPoints, count)
: 0;
Span<Vector2> retainedPoints = stackalloc Vector2[
DeathWingDomainChunkOverlapPoints];
Span<float> retainedOpacities = stackalloc float[
DeathWingDomainChunkOverlapPoints];
for (int index = 0; index < retainedCount; index++)
{
int sourceIndex = count - retainedCount + index;
retainedPoints[index] = deathWingDomainChunkPoints[sourceIndex];
retainedOpacities[index] =
deathWingDomainChunkOpacities[sourceIndex];
}
float retainedMastery = deathWingDomainChunkMastery;
float opacity = 0f;
foreach (float sample in deathWingDomainChunkOpacities)
opacity += sample;
opacity /= deathWingDomainChunkOpacities.Count;
float width = 32f + deathWingDomainChunkMastery * 8f;
deathWingDomainChunkSequence = unchecked(
deathWingDomainChunkSequence + 1);
DeathWingDomainTrailProjectile.Spawn(
Player.GetSource_Misc("SoulHarvest:DeathWingDomainTrail"),
Player.whoAmI, deathWingDomainChunkPoints, width, opacity,
deathWingDomainChunkSequence);
DiscardDeathWingDomainChunk();
if (!keepTail)
return;
for (int index = 0; index < retainedCount; index++)
{
deathWingDomainChunkPoints.Add(retainedPoints[index]);
deathWingDomainChunkOpacities.Add(retainedOpacities[index]);
}
deathWingDomainChunkMastery = retainedMastery;
deathWingDomainChunkHasNewGeometry = false;
}
private void DiscardDeathWingDomainChunk()
{
deathWingDomainChunkPoints.Clear();
deathWingDomainChunkOpacities.Clear();
deathWingDomainChunkMastery = 0f;
deathWingDomainChunkHasNewGeometry = false;
}
private static float GetDeathWingRiftOpacity(float speedProgress)
{
speedProgress = MathHelper.Clamp(speedProgress, 0f, 1f);
float smoothedSpeed = speedProgress * speedProgress
* (3f - 2f * speedProgress);
return MathHelper.Lerp(0.08f, 0.92f, smoothedSpeed);
}
private void SubmitDeathWingDomainRifts()
{
if (Main.dedServ || DeathWingTrailCount < 4
|| DeathWingRiftStrength <= 0.035f)
{
return;
}
int pointCount = Math.Min(DeathWingTrailCount,
8 + (int)(DeathWingTrailMastery * 10f));
float width = 32f + DeathWingTrailMastery * 8f;
float opacity = DeathWingTrailOpacity
* GetDeathWingRiftOpacity(DeathWingRiftStrength);
DeathDomainTrailVisualSystem.Record(Player.whoAmI,
DeathWingBodyRiftIdentity,
new ArraySegment<Vector2>(DeathWingBodyTrail, 0, pointCount),
width, opacity, mergeOverlappingRims: true,
taperEnds: false, noiseFadeEnds: true);
}
private void ClearDeathWingTrail()
{
DeathWingTrailCount = 0;
DeathWingTrailMastery = 0f;
DeathWingTrailOpacity = 0f;
DeathWingRiftStrength = 0f;
deathWingTrailUpdateTick = ulong.MaxValue;
}
public override void SyncPlayer(int toWho, int fromWho, bool newPlayer)
{
if (Main.netMode == NetmodeID.SinglePlayer
|| Main.netMode == NetmodeID.MultiplayerClient && Player.whoAmI != Main.myPlayer)
{
return;
}
// A joining client uploads souls, domain state and the full Reaper tree in
// one packet. Splitting those character-owned values allowed mutations to
// occur between two independently accepted initialization packets.
if (Main.netMode == NetmodeID.MultiplayerClient)
{
SoulHarvest.SendReaperProgression(Player, toWho, fromWho);
return;
}
if (!ServerCharacterStateReady)
return;
ModPacket packet = Mod.GetPacket();
packet.Write((byte)SoulHarvest.MessageType.SyncSoulPlayer);
packet.Write((byte)Player.whoAmI);
packet.Write(Souls);
packet.Write(DeathDomainEnabled);
packet.Write(DeathDomainDescended);
DeathDomainProgression.NetSend(packet);
packet.Send(toWho, fromWho);
SoulHarvest.SendReaperProgression(Player, toWho, fromWho);
SoulHarvest.SendReaperCombat(Player, toWho, fromWho);
}
internal void ReceiveSouls(int souls)
{
Souls = Math.Max(0, souls);
}
internal void ReceiveDeathDomainState(bool enabled, bool descended)
{
bool opening = enabled && !DeathDomainEnabled;
bool descending = enabled && descended && !DeathDomainDescended;
DeathDomainEnabled = enabled;
DeathDomainDescended = enabled && descended;
if (!enabled)
deathDomainTimer = 0;
if ((opening || descending) && Main.netMode != NetmodeID.Server && !Main.dedServ)
{
// Two restrained layers give the expansion both a low spatial rupture
// and a sharp soul-metal opening without requiring a custom asset.
SoundEngine.PlaySound(SoundID.Roar with
{
Volume = 0.42f,
Pitch = -0.48f,
PitchVariance = 0.06f
}, Player.Center);
SoundEngine.PlaySound(SoundID.Item122 with
{
Volume = 1.05f,
Pitch = -0.28f,
PitchVariance = 0.08f
}, Player.Center);
}
}
internal bool ServerCharacterStateReady => Main.netMode != NetmodeID.Server
|| serverSoulBalanceInitialized && serverReaperProgressionInitialized;
internal bool TryInitializeServerCharacterState(
int souls,
bool deathDomainEnabled,
bool deathDomainDescended,
BinaryReader reader)
{
if (Main.netMode != NetmodeID.Server)
return false;
if (serverSoulBalanceInitialized || serverReaperProgressionInitialized)
{
// The client retries until it receives an acknowledgement. Retried
// uploads are not authority, but their full payload must still be
// consumed or tModLoader disconnects the sender for an under-read.
DeathDomainProgression discardedDomain = new();
ReaperProgressionState discardedProgression = new();
discardedDomain.NetReceive(reader);
discardedProgression.NetReceive(reader);
return false;
}
Souls = Math.Max(0, souls);
DeathNecklace? necklace = FindEquippedDeathNecklace();
DeathDomainEnabled = deathDomainEnabled && necklace is not null;
DeathDomainDescended = DeathDomainEnabled
&& deathDomainDescended
&& necklace?.DeathDescentUnlocked == true;
DeathDomainProgression.NetReceive(reader);
ReaperProgression.NetReceive(reader);
VisibleReaperForm = ReaperCombatRegistry.ResolveUsableForm(ReaperProgression);
VisibleReaperStage = ReaperProgression.GetStage(VisibleReaperForm);
serverSoulBalanceInitialized = true;
serverReaperProgressionInitialized = true;
return true;
}
internal bool TrySpendSouls(int amount, bool sync = true)
{
if (amount < 0
|| Souls < amount
|| Main.netMode == NetmodeID.MultiplayerClient
|| !ServerCharacterStateReady)
return false;
Souls -= amount;
if (sync)
SyncSoulBalance();
return true;
}
public void AddSouls(int amount, bool announce = false)
{
if (amount <= 0 || Main.netMode == NetmodeID.MultiplayerClient || !ServerCharacterStateReady)
return;
amount = DeathDomainProgression.ApplySoulRefinement(amount);
Souls = (int)Math.Min(int.MaxValue, (long)Souls + amount);
SyncSoulBalance();
if (!announce)
return;
if (Main.netMode == NetmodeID.Server)
{
ChatHelper.SendChatMessageToClient(
NetworkText.FromKey("Mods.SoulHarvest.Messages.BossSoulReward", amount),
new Color(90, 235, 255),
Player.whoAmI);
}
else if (Player.whoAmI == Main.myPlayer)
{
Main.NewText(Language.GetTextValue("Mods.SoulHarvest.Messages.BossSoulReward", amount), new Color(90, 235, 255));
}
}
public bool TryExtractEssence()
{
if (Main.netMode == NetmodeID.MultiplayerClient || !ServerCharacterStateReady)
return false;
if (Souls < SoulsPerEssence)
{
SoulHarvest.SendOperationResult(Player, SoulHarvest.OperationResult.NotEnoughSouls);
return false;
}
int essenceType = ModContent.ItemType<Soul>();
if (!TryFindInventoryInsertionSlot(essenceType, out int changedSlot))
{
SoulHarvest.SendOperationResult(Player, SoulHarvest.OperationResult.InventoryFull);
return false;
}
Souls -= SoulsPerEssence;
Item destination = Player.inventory[changedSlot];
if (destination.type == essenceType && destination.stack > 0)
destination.stack++;
else
{
destination.SetDefaults(essenceType);
destination.stack = 1;
}
SoulHarvest.SendReaperProgressionTransaction(Player, new[] { changedSlot });
SoulHarvest.SendOperationResult(Player, SoulHarvest.OperationResult.EssenceExtracted);
return true;
}
public bool TryAbsorbEssence()
{
if (Main.netMode == NetmodeID.MultiplayerClient || !ServerCharacterStateReady)
return false;
int essenceType = ModContent.ItemType<Soul>();
int changedSlot = -1;
for (int slot = 0; slot < 58; slot++)
{
Item item = Player.inventory[slot];
if (item.type != essenceType || item.stack <= 0)
continue;
item.stack--;
if (item.stack <= 0)
item.TurnToAir();
changedSlot = slot;
break;
}
if (changedSlot < 0)
{
SoulHarvest.SendOperationResult(Player, SoulHarvest.OperationResult.NotEnoughEssence);
return false;
}
Souls = (int)Math.Min(int.MaxValue, (long)Souls + SoulsPerEssence);
SoulHarvest.SendReaperProgressionTransaction(Player, new[] { changedSlot });
SoulHarvest.SendOperationResult(Player, SoulHarvest.OperationResult.EssenceAbsorbed);
return true;
}
private bool TryFindInventoryInsertionSlot(int itemType, out int slot)
{
slot = -1;
int emptySlot = -1;
int slotCount = Math.Min(58, Player.inventory.Length);
for (int index = 0; index < slotCount; index++)
{
Item item = Player.inventory[index];
if (item.type == itemType && item.stack > 0 && item.stack < item.maxStack)
{
slot = index;
return true;
}
if (emptySlot < 0 && item.IsAir)
emptySlot = index;
}
slot = emptySlot;
return slot >= 0;
}
public int TryLifeSteal(int damageDone, int lifeStealLevel)
{
if (lifeStealLevel <= 0
|| damageDone <= 0
|| lifeStealCooldown > 0
|| Player.dead
|| Main.netMode == NetmodeID.MultiplayerClient && Player.whoAmI != Main.myPlayer)
{
return 0;
}
int missingLife = Player.statLifeMax2 - Player.statLife;
if (missingLife <= 0)
return 0;
int amount = Math.Min(missingLife, Math.Max(1, (int)Math.Ceiling(damageDone * lifeStealLevel * 0.01f)));
Player.Heal(amount);
lifeStealCooldown = 12;
if (Main.netMode != NetmodeID.SinglePlayer)
NetMessage.SendData(MessageID.PlayerLifeMana, -1, -1, null, Player.whoAmI);
return amount;
}
internal int ApplyDeathReaperLifeSteal(int damageDone, Vector2 visualSource,
int requestedLevel = 0)
{
if (damageDone <= 0 || Player.dead || Main.netMode == NetmodeID.MultiplayerClient)
return 0;
int lifeStealLevel = Math.Clamp(Math.Max(requestedLevel,
DeathDomainProgression.LifeStealLevel), 0, DeathDomainProgression.MaxLifeStealLevel);
if (lifeStealLevel <= 0)
return 0;
int missingLife = Math.Max(0, Player.statLifeMax2 - Player.statLife);
int perHitCap = Math.Max(1, (int)Math.Ceiling(Player.statLifeMax2 * 0.025f));
int amount = Math.Min(missingLife, Math.Min(perHitCap,
Math.Max(1, (int)Math.Ceiling(damageDone * lifeStealLevel * 0.01f))));
if (amount > 0)
{
Player.Heal(amount);
if (Main.netMode == NetmodeID.Server)
NetMessage.SendData(MessageID.PlayerLifeMana, -1, -1, null, Player.whoAmI);
}
// Death Reaper attacks always show the return stream, including at full life.
// This makes every successful slash readable without granting phantom healing.
LifeStealVisuals.Spawn(visualSource, Player.whoAmI, lifeStealLevel, amount,
forceFeedback: true);
return amount;
}
internal int TakeNextSwingDirection(int facingDirection)
{
int direction = swingComboDirection * facingDirection;
swingComboDirection *= -1;
return direction;
}
internal void StartSickleAlternateCooldown(int frames)
{
if (frames <= 0 || SickleAlternateCooldown > 0)
return;
SickleAlternateCooldown = frames;
SickleAlternateCooldownMax = frames;
}
public float GetReaperEnergy(ReaperFormId form)
{
int index = (int)form;
return index > (int)ReaperFormId.Base && index < reaperEnergy.Length
? MathHelper.Clamp(reaperEnergy[index], 0f, 100f)
: 0f;
}
public int GetReaperUltimateCooldown(ReaperFormId form)
{
int index = (int)form;
return index >= 0 && index < reaperUltimateCooldowns.Length
? Math.Max(0, reaperUltimateCooldowns[index])
: 0;
}
internal bool TryGainReaperEnergy(ReaperFormId form, float amount)
{
if (amount <= 0f || Main.netMode == NetmodeID.MultiplayerClient)
return false;
bool unlocked = form == ReaperFormId.Death
? ReaperProgression.DeathFormUnlocked
: ReaperDefinitions.IsBranchForm(form) && ReaperProgression.GetStage(form) == ReaperStage.StageIII;
int index = (int)form;
if (!unlocked || index <= (int)ReaperFormId.Base || index >= reaperEnergy.Length)
return false;
float gainMultiplier = 1f + ReaperProgression.GetCommonNodeLevel(ReaperCommonNode.EnergyGain) * 0.2f;
float previous = reaperEnergy[index];
reaperEnergy[index] = MathHelper.Clamp(previous + amount * gainMultiplier, 0f, 100f);
if ((int)previous != (int)reaperEnergy[index] || reaperEnergy[index] >= 100f)
SyncReaperCombat();
return reaperEnergy[index] > previous;
}
#if DEBUG
internal void DebugSetReaperEnergy(ReaperFormId form, float amount)
{
if (Main.netMode == NetmodeID.MultiplayerClient)
return;
int index = (int)form;
if (index <= (int)ReaperFormId.Base || index >= reaperEnergy.Length)
return;
reaperEnergy[index] = MathHelper.Clamp(amount, 0f, 100f);
reaperUltimateCooldowns[index] = 0;
ReaperExhaustionCooldown = 0;
SyncReaperCombat();
}
#endif
internal void RecordReaperAttackHit(ReaperFormId form, ReaperHitKind kind)
{
// Ultimate charge has one universal source: an attack actually connecting
// with an enemy. Damage-over-time ticks and the ultimate itself never feed
// the meter, while primary, derived, returning and special hits all count.
if (kind is ReaperHitKind.Ultimate or ReaperHitKind.UltimateDerived or ReaperHitKind.DamageOverTime)
return;
TryGainReaperEnergy(form, 4f);
}
internal bool TryStartReaperSpecialCooldown(int frames)
{
if (frames <= 0 || ReaperSpecialCooldown > 0)
return false;
float reduction = Math.Clamp(
ReaperProgression.GetCommonNodeLevel(ReaperCommonNode.SpecialCooldown) * 0.1f, 0f, 0.5f);
int adjustedFrames = Math.Max(30, (int)Math.Ceiling(frames * (1f - reduction)));
ReaperSpecialCooldown = adjustedFrames;
ReaperSpecialCooldownMax = adjustedFrames;
SyncReaperCombat();
return true;
}
internal void ReduceReaperSpecialCooldown(int frames)
{
if (frames <= 0 || Main.netMode == NetmodeID.MultiplayerClient || ReaperSpecialCooldown <= 0)
return;
int previous = ReaperSpecialCooldown;
ReaperSpecialCooldown = Math.Max(0, ReaperSpecialCooldown - frames);
if (ReaperSpecialCooldown != previous)
SyncReaperCombat();
}
internal void RefundFailedReaperSpecial()
{
if (Main.netMode == NetmodeID.MultiplayerClient)
return;
ReaperSpecialCooldown = 0;
ReaperSpecialCooldownMax = 0;
SyncReaperCombat();
}
internal bool TrySwitchOrQueueReaperForm(ReaperFormId form)
{
if (Main.netMode == NetmodeID.MultiplayerClient)
return false;
ulong now = Main.GameUpdateCount;
bool assemblyActive = ReaperFormAssemblyProjectile.TryGetActive(
Player, out ReaperFormAssemblyProjectile? activeAssembly);
if (lastReaperFormSwitchRequestTick != 0 && now - lastReaperFormSwitchRequestTick < 10UL)
{
// Wheel input can commit twice while closing. Treat an identical
// request as an idempotent acknowledgement instead of reporting a
// misleading progression/prerequisite failure to the client.
if (queuedReaperForm == form
|| activeAssembly?.TargetForm == form
|| ReaperProgression.CurrentForm == form)
return true;
}
lastReaperFormSwitchRequestTick = now;
if (!assemblyActive && ReaperProgression.CurrentForm == form)
return true;
if (IsReaperCombatActionBusy() || assemblyActive)
{
queuedReaperForm = form;
return true;
}
queuedReaperForm = null;
return TryStartReaperFormAssembly(form);
}
private void TryApplyQueuedReaperForm()
{
if (Main.netMode == NetmodeID.MultiplayerClient
|| queuedReaperForm is not ReaperFormId form
|| IsReaperCombatActionBusy()
|| ReaperFormAssemblyProjectile.TryGetActive(Player, out _))
return;
queuedReaperForm = null;
if (ReaperProgression.CurrentForm != form)
TryStartReaperFormAssembly(form);
}
private bool IsReaperCombatActionBusy()
{
return Player.itemAnimation > 0
|| Player.itemTime > 0
|| Player.ownedProjectileCounts[ModContent.ProjectileType<SickleSwingProjectile>()] > 0
|| Player.ownedProjectileCounts[ModContent.ProjectileType<ReaperActionControllerProjectile>()] > 0;
}
internal bool IsReaperFormAssemblyActive()
=> ReaperFormAssemblyProjectile.TryGetActive(Player, out _);
internal bool TryCompleteReaperFormAssembly(int projectileIndex,
ReaperFormId form)
{
if (Main.netMode == NetmodeID.MultiplayerClient
|| projectileIndex < 0 || projectileIndex >= Main.maxProjectiles)
{
return false;
}
Projectile projectile = Main.projectile[projectileIndex];
if (!projectile.active || projectile.owner != Player.whoAmI
|| projectile.ModProjectile
is not ReaperFormAssemblyProjectile assembly
|| assembly.TargetForm != form
|| !assembly.AssemblyComplete
|| !ReaperProgression.TrySetCurrentForm(form))
{
return false;
}
VisibleReaperForm = ReaperCombatRegistry.ResolveUsableForm(
ReaperProgression);
VisibleReaperStage = ReaperProgression.GetStage(VisibleReaperForm);
SyncReaperProgression();
return true;
}
private bool TryStartReaperFormAssembly(ReaperFormId form)
{
ReaperStage stage = form switch
{
ReaperFormId.Base => ReaperStage.StageI,
ReaperFormId.Death => ReaperStage.StageIII,
_ => ReaperProgression.GetStage(form)
};
int level = ReaperProgression.GetCommonNodeLevel(
ReaperCommonNode.AssemblySpeed);
int duration = ReaperDefinitions.GetAssemblyFrames(level);
int rangeLevel = ReaperProgression.GetCommonNodeLevel(
ReaperCommonNode.DeathRange);
bool started = ReaperFormAssemblyProjectile.Spawn(Player, form, stage,
duration, rangeLevel) >= 0;
#if DEBUG
Mod.Logger.Info(
$"SOULHARVEST_FORM_ASSEMBLY: start player={Player.whoAmI} from={ReaperProgression.CurrentForm} target={form} duration={duration} started={started}");
#endif
return started;
}
internal bool TryConsumeReaperUltimate(ReaperFormId form, int cooldownFrames)
{
if (Main.netMode == NetmodeID.MultiplayerClient
|| form != ReaperProgression.CurrentForm
|| ReaperExhaustionCooldown > 0
|| GetReaperUltimateCooldown(form) > 0
|| GetReaperEnergy(form) < 100f)
{
return false;
}
bool unlocked = form == ReaperFormId.Death
? ReaperProgression.DeathFormUnlocked
: ReaperDefinitions.IsBranchForm(form) && ReaperProgression.GetStage(form) == ReaperStage.StageIII;
if (!unlocked)
return false;
int index = (int)form;
reaperEnergy[index] = 0f;
reaperUltimateCooldowns[index] = Math.Max(1, cooldownFrames);
ReaperExhaustionCooldown = 60 * 8;
SyncReaperCombat();
return true;
}
internal void RefundFailedReaperUltimate(ReaperFormId form)
{
if (Main.netMode == NetmodeID.MultiplayerClient)
return;
int index = (int)form;
if (index <= (int)ReaperFormId.Base || index >= reaperEnergy.Length)
return;
reaperEnergy[index] = 100f;
reaperUltimateCooldowns[index] = 0;
ReaperExhaustionCooldown = 0;
SyncReaperCombat();
}
internal void RecordDeathCycleHit(int phase)
{
if (phase < 0 || phase >= 6 || ReaperProgression.CurrentForm != ReaperFormId.Death)
return;
byte bit = (byte)(1 << phase);
if ((deathCycleMask & bit) != 0)
return;
deathCycleMask |= bit;
if (deathCycleMask == 0x3F)
deathCycleMask = 0;
}
internal void ClearReaperCombatRuntime(bool sync)
{
Array.Clear(reaperEnergy, 0, reaperEnergy.Length);
Array.Clear(reaperUltimateCooldowns, 0, reaperUltimateCooldowns.Length);
ReaperSpecialCooldown = 0;
ReaperSpecialCooldownMax = 0;
ReaperExhaustionCooldown = 0;
deathCycleMask = 0;
queuedReaperForm = null;
pendingReaperDomainHarvests.Clear();
Player.GetModPlayer<ReaperCombatPlayer>().ClearRuntime();
if (sync)
SyncReaperCombat();
}
internal void ResetReaperTreeForDebug()
{
ReaperProgression.ResetTreeForDebug();
VisibleReaperForm = ReaperFormId.Base;
VisibleReaperStage = ReaperStage.StageI;
ClearReaperCombatRuntime(sync: true);
if (Main.netMode == NetmodeID.Server)
SoulHarvest.SendReaperProgressionTransaction(Player, Array.Empty<int>());
}
internal void WriteReaperProgression(BinaryWriter writer) => ReaperProgression.NetSend(writer);
internal void WriteDeathDomainProgression(BinaryWriter writer) => DeathDomainProgression.NetSend(writer);
internal void ReceiveDeathDomainProgression(BinaryReader reader) => DeathDomainProgression.NetReceive(reader);
internal void ReceiveReaperProgression(BinaryReader reader)
{
ReaperProgression.NetReceive(reader);
VisibleReaperForm = ReaperCombatRegistry.ResolveUsableForm(ReaperProgression);
VisibleReaperStage = ReaperProgression.GetStage(VisibleReaperForm);
}
internal void ReceiveReaperPublicState(ReaperFormId form, ReaperStage stage)
{
VisibleReaperForm = Enum.IsDefined(form) ? form : ReaperFormId.Base;
VisibleReaperStage = stage is >= ReaperStage.Locked and <= ReaperStage.StageIII ? stage : ReaperStage.Locked;
}
internal void WriteReaperCombat(BinaryWriter writer)
{
writer.Write((byte)ReaperProgression.CurrentForm);
for (int index = 0; index < reaperEnergy.Length; index++)
writer.Write((ushort)Math.Clamp((int)Math.Round(reaperEnergy[index] * 10f), 0, 1000));
writer.Write((ushort)Math.Clamp(ReaperSpecialCooldown, 0, ushort.MaxValue));
writer.Write((ushort)Math.Clamp(ReaperSpecialCooldownMax, 0, ushort.MaxValue));
writer.Write((ushort)Math.Clamp(ReaperExhaustionCooldown, 0, ushort.MaxValue));
for (int index = 0; index < reaperUltimateCooldowns.Length; index++)
writer.Write((ushort)Math.Clamp(reaperUltimateCooldowns[index], 0, ushort.MaxValue));
writer.Write(deathCycleMask);
Player.GetModPlayer<ReaperCombatPlayer>().WriteOwnerRuntime(writer);
}
internal void ReceiveReaperCombat(BinaryReader reader)
{
ReaperProgression.TrySetCurrentForm((ReaperFormId)reader.ReadByte());
VisibleReaperForm = ReaperCombatRegistry.ResolveUsableForm(ReaperProgression);
VisibleReaperStage = ReaperProgression.GetStage(VisibleReaperForm);
for (int index = 0; index < reaperEnergy.Length; index++)
reaperEnergy[index] = MathHelper.Clamp(reader.ReadUInt16() / 10f, 0f, 100f);
ReaperSpecialCooldown = reader.ReadUInt16();
ReaperSpecialCooldownMax = reader.ReadUInt16();
ReaperExhaustionCooldown = reader.ReadUInt16();
for (int index = 0; index < reaperUltimateCooldowns.Length; index++)
reaperUltimateCooldowns[index] = reader.ReadUInt16();
deathCycleMask = reader.ReadByte();
Player.GetModPlayer<ReaperCombatPlayer>().ReceiveOwnerRuntime(reader);
}
public void SyncReaperProgression() => SoulHarvest.SendReaperProgression(Player);
private void SyncReaperCombat() => SoulHarvest.SendReaperCombat(Player);
internal void SyncReaperCombatRuntime() => SyncReaperCombat();
public override void ProcessTriggers(TriggersSet triggersSet)
{
bool reaperInputBlocked = Main.playerInventory
|| Main.drawingPlayerChat
|| Main.editSign
|| Main.editChest
|| Main.mapFullscreen
|| Player.talkNPC >= 0
|| Player.mouseInterface
|| Main.InGameUI.CurrentState is not null
|| ModContent.GetInstance<DeathAltarUISystem>().IsVisible
|| ReaperFormWheelUISystem.IsWheelOpen;
bool holdingReaperSickle = Player.HeldItem.ModItem is NormalSickle;
bool deathFormSelected = holdingReaperSickle
&& ReaperCombatRegistry.ResolveUsableForm(ReaperProgression)
== ReaperFormId.Death;
bool primaryInputHeld = deathFormSelected && Player.controlUseItem;
if (reaperPrimaryInputWasHeld && !primaryInputHeld)
{
ReaperCombatService.ReleaseDeathPrimaryHold(Player);
if (Main.netMode == NetmodeID.MultiplayerClient)
SoulHarvest.SendReaperPrimaryRelease(Player);
}
reaperPrimaryInputWasHeld = primaryInputHeld;
// Mouse2 is Death's documented input. Keep the configurable keybind, but
// also read the physical button so a stale/missing keybind profile cannot
// silently disable the whole right-click request chain.
bool rawSpecialHeld = Main.mouseRight;
bool specialHeld = SoulHarvest.SpecialAttackKeybind?.Current == true
|| rawSpecialHeld;
bool specialPressed = SoulHarvest.SpecialAttackKeybind?.JustPressed == true
|| rawSpecialHeld && !rawSpecialInputWasHeld;
bool specialReleased = SoulHarvest.SpecialAttackKeybind?.JustReleased == true
|| !specialHeld && rawSpecialInputWasHeld;
// A release must still reach the server if an inventory or another UI was
// opened during the charge. The controller also auto-releases at its cap.
if (holdingReaperSickle && specialReleased)
{
if (Main.netMode == NetmodeID.MultiplayerClient)
{
// Release the replicated owner controller immediately as well as
// notifying the server. A quick tap can end before the server's
// spawn packet arrives; the controller also has an owner-input
// fallback for that late-arrival case.
ReaperActionControllerProjectile.RequestLocalOwnerRelease(Player);
SoulHarvest.SendReaperSpecialRelease(Player);
}
else
ReaperCombatService.TryReleaseSpecial(Player);
}
if (holdingReaperSickle && !reaperInputBlocked && !Player.dead && !Player.CCed && !Player.noItems)
{
if (specialHeld)
Player.controlUseTile = false;
Vector2 cursorOffset = Main.MouseWorld - Player.MountedCenter;
Vector2 aim = cursorOffset.SafeNormalize(Vector2.UnitX * Player.direction);
bool deathInvocationHeld = specialHeld
&& ReaperCombatRegistry.ResolveUsableForm(ReaperProgression) == ReaperFormId.Death;
if (specialPressed)
{
if (Main.netMode == NetmodeID.MultiplayerClient)
SoulHarvest.SendReaperAction(Player, ultimate: false, cursorOffset);
else
ReaperCombatService.TryStartSpecial(Player, cursorOffset);
}
else if (deathInvocationHeld && Main.GameUpdateCount % 5UL == (ulong)(Player.whoAmI % 5))
{
int controllerType = ModContent.ProjectileType<ReaperActionControllerProjectile>();
bool invocationExists = Player.ownedProjectileCounts[controllerType] > 0;
if (Main.netMode == NetmodeID.MultiplayerClient)
{
// A press can reach the server during the last primary-swing
// frame and be rejected legitimately. While Death's invocation
// key remains held, retry the start at a bounded cadence until
// its controller is replicated; afterwards packets only update
// the cursor target.
if (invocationExists)
SoulHarvest.SendReaperSpecialAim(Player, cursorOffset);
else
SoulHarvest.SendReaperAction(Player, ultimate: false, cursorOffset);
}
else if (invocationExists)
{
ReaperCombatService.TryUpdateSpecialTarget(Player, cursorOffset);
}
else
{
ReaperCombatService.TryStartSpecial(Player, cursorOffset);
}
}
if (SoulHarvest.UltimateKeybind?.JustPressed == true)
{
if (Main.netMode == NetmodeID.MultiplayerClient)
SoulHarvest.SendReaperAction(Player, ultimate: true, cursorOffset);
else
ReaperCombatService.TryStartUltimate(Player, cursorOffset);
}
}
rawSpecialInputWasHeld = rawSpecialHeld;
ProcessDeathDomainKeybind();
}
private void ProcessDeathDomainKeybind()
{
ModKeybind? keybind = SoulHarvest.ToggleDeathDomainKeybind;
if (keybind is null)
return;
if (keybind.JustPressed)
{
deathDomainKeyHoldFrames = 0;
deathDomainLongPressHandled = false;
}
if (keybind.Current)
{
deathDomainKeyHoldFrames++;
if (!deathDomainLongPressHandled
&& deathDomainKeyHoldFrames >= DeathDomainLongPressFrames)
{
deathDomainLongPressHandled = true;
TryToggleDeathDomainDescent();
}
return;
}
if (keybind.JustReleased && !deathDomainLongPressHandled)
TryToggleFiniteDeathDomain();
if (keybind.JustReleased || deathDomainKeyHoldFrames > 0)
{
deathDomainKeyHoldFrames = 0;
deathDomainLongPressHandled = false;
}
}
private void TryToggleFiniteDeathDomain()
{
DeathNecklace? necklace = ActiveDeathNecklace ?? FindEquippedDeathNecklace();
if (necklace is null)
return;
bool enabled = !DeathDomainEnabled;
ReceiveDeathDomainState(enabled, descended: false);
SoulHarvest.SendDeathDomainState(Player, DeathDomainEnabled, DeathDomainDescended);
}
private void TryToggleDeathDomainDescent()
{
DeathNecklace? necklace = ActiveDeathNecklace ?? FindEquippedDeathNecklace();
if (necklace is null)
return;
if (!necklace.DeathDescentUnlocked)
return;
bool descended = !DeathDomainDescended;
ReceiveDeathDomainState(enabled: true, descended: descended);
SoulHarvest.SendDeathDomainState(Player, DeathDomainEnabled, DeathDomainDescended);
}
internal void SetDeathNecklace(DeathNecklace necklace)
{
if (ActiveDeathNecklace is null || necklace.MasteryScore > ActiveDeathNecklace.MasteryScore)
ActiveDeathNecklace = necklace;
}
private DeathNecklace? FindEquippedDeathNecklace()
{
DeathNecklace? best = null;
foreach (Item item in Player.armor)
{
if (item.ModItem is DeathNecklace candidate && (best is null || candidate.MasteryScore > best.MasteryScore))
best = candidate;
}
return best;
}
internal void MergeLegacyDeathNecklaceProgression(DeathNecklace necklace)
{
bool changed = DeathDomainProgression.MergeLegacyNecklaceLevels(
necklace.LegacyFrequencyLevel,
necklace.LegacyDamageLevel,
necklace.LegacyVolleyLevel,
necklace.LegacyLifeStealLevel);
if (!changed)
return;
if (Main.netMode == NetmodeID.Server)
SyncPlayer(Player.whoAmI, -1, false);
else if (Main.netMode == NetmodeID.MultiplayerClient && Player.whoAmI == Main.myPlayer)
SoulHarvest.SendReaperProgression(Player);
}
internal DeathNecklace? GetEquippedDeathNecklace()
=> ActiveDeathNecklace ?? FindEquippedDeathNecklace();
internal bool IsDeathDomainDescended(DeathNecklace? necklace)
=> DeathDomainEnabled
&& DeathDomainDescended
&& necklace?.DeathDescentUnlocked == true;
internal void SpawnDeathDomainHarvestFromReaperRift(NPC target,
bool requiem, float rotation)
{
if (Main.netMode == NetmodeID.MultiplayerClient || !target.active)
return;
SpawnDeathDomainHarvestSlash(target, DeathDomainProgression.VisualMastery,
requiem, rotation);
}
internal void QueueDeathDomainHarvestFromReaperHit(NPC target,
float rotation, int deathUltimateActionId = -1)
{
if (Main.netMode == NetmodeID.MultiplayerClient || !target.active)
return;
const int telegraphFrames = 12;
float visualMastery = DeathDomainProgression.VisualMastery;
float scale = MathHelper.Clamp(Math.Max(target.width, target.height) / 52f,
0.85f, 2.4f);
deathDomainVolleyCounter++;
bool requiem = IsDeathDomainDescended(GetEquippedDeathNecklace())
&& deathDomainVolleyCounter % 4 == 0;
if (Main.netMode == NetmodeID.Server)
{
SoulHarvest.BroadcastDeathDomainTelegraph(Player, target,
telegraphFrames, DeathDomainProgression.SlashCount, visualMastery, rotation,
scale);
}
else
{
int projectileIndex = Projectile.NewProjectile(
Player.GetSource_Misc("SoulHarvest:ReaperNecklaceTelegraph"),
target.Center,
Vector2.Zero,
ModContent.ProjectileType<DeathDomainHarvestTelegraphProjectile>(),
0,
0f,
Player.whoAmI,
target.whoAmI,
telegraphFrames,
DeathDomainProgression.SlashCount + visualMastery * 0.1f);
if (projectileIndex >= 0 && projectileIndex < Main.maxProjectiles)
{
Projectile telegraph = Main.projectile[projectileIndex];
telegraph.rotation = rotation;
telegraph.scale = scale;
}
}
pendingReaperDomainHarvests.Add(new PendingReaperDomainHarvest(
target.whoAmI,
telegraphFrames,
rotation,
visualMastery,
requiem,
deathUltimateActionId));
}
#if DEBUG
internal bool HasPendingReaperDomainHarvest(int npcIndex)
=> pendingReaperDomainHarvests.Exists(pending
=> pending.NpcIndex == npcIndex);
internal bool HasPendingDeathDomainStrike(int npcIndex)
=> pendingDeathDomainStrikes.Exists(pending
=> pending.NpcIndex == npcIndex);
#endif
private void UpdatePendingReaperDomainHarvests()
{
if (Main.netMode == NetmodeID.MultiplayerClient
|| pendingReaperDomainHarvests.Count == 0)
{
return;
}
for (int index = pendingReaperDomainHarvests.Count - 1;
index >= 0; index--)
{
PendingReaperDomainHarvest pending =
pendingReaperDomainHarvests[index];
int remainingFrames = pending.RemainingFrames - 1;
if (remainingFrames > 0)
{
pendingReaperDomainHarvests[index] = pending with
{
RemainingFrames = remainingFrames
};
continue;
}
pendingReaperDomainHarvests.RemoveAt(index);
if (pending.NpcIndex < 0 || pending.NpcIndex >= Main.maxNPCs)
continue;
NPC target = Main.npc[pending.NpcIndex];
if (!ReaperTargeting.IsValidWeaponTarget(target))
{
continue;
}
SpawnDeathDomainHarvestSlash(target, pending.VisualMastery,
pending.Requiem, pending.Rotation,
pending.DeathUltimateActionId);
}
}
private void UpdateDeathDomain()
{
// UpdateAccessory normally supplies this reference. The direct equipment scan
// keeps dedicated-server attacks stable on ticks where accessory recalculation
// did not expose the ModItem instance before PostUpdate.
DeathNecklace? necklace = ActiveDeathNecklace ?? FindEquippedDeathNecklace();
if (DeathDomainEnabled && necklace is null)
{
ReceiveDeathDomainState(enabled: false, descended: false);
if (Main.netMode == NetmodeID.Server)
SoulHarvest.BroadcastDeathDomainState(Player.whoAmI, enabled: false, descended: false);
else
SoulHarvest.SendDeathDomainState(Player, enabled: false, descended: false);
}
if (!DeathDomainEnabled || necklace is null || Player.dead)
{
deathDomainTimer = 0;
ClearDeathDomainTelegraphs();
return;
}
// Percentage damage is authoritative. Clients receive synchronized NPC strikes
// and the dedicated visual projectile spawned by the server.
if (Main.netMode == NetmodeID.MultiplayerClient)
return;
bool descended = IsDeathDomainDescended(necklace);
ApplyDeathDomainForces(necklace, descended);
ApplyDeathDomainItemAttraction();
List<NPC> targets = FindDeathDomainTargets(necklace, descended);
float visualMastery = DeathDomainProgression.VisualMastery;
UpdateDeathDomainTelegraphs(targets, visualMastery);
deathDomainTimer++;
if (deathDomainTimer < DeathDomainProgression.SpawnInterval)
return;
deathDomainTimer = 0;
if (targets.Count == 0)
{
ClearDeathDomainTelegraphs();
return;
}
deathDomainVolleyCounter++;
bool requiem = descended && deathDomainVolleyCounter % 4 == 0;
foreach (NPC target in targets)
{
float rotation = deathDomainPendingCuts.TryGetValue(target.whoAmI, out PendingDeathDomainCut pending)
? pending.Rotation
: Main.rand.NextFloat(MathHelper.TwoPi);
SpawnDeathDomainHarvestSlash(target, visualMastery, requiem, rotation);
}
ClearDeathDomainTelegraphs();
}
private void UpdateDeathDomainTelegraphs(List<NPC> targets, float visualMastery)
{
HashSet<int> activeTargets = [];
foreach (NPC target in targets)
activeTargets.Add(target.whoAmI);
List<int> staleTargets = [];
foreach (int npcIndex in deathDomainPendingCuts.Keys)
if (!activeTargets.Contains(npcIndex))
staleTargets.Add(npcIndex);
foreach (int npcIndex in staleTargets)
RemoveDeathDomainTelegraph(npcIndex);
int remainingFrames = Math.Max(1, DeathDomainProgression.SpawnInterval - deathDomainTimer);
foreach (NPC target in targets)
{
if (deathDomainPendingCuts.ContainsKey(target.whoAmI))
continue;
float rotation = Main.rand.NextFloat(MathHelper.TwoPi);
float scale = MathHelper.Clamp(Math.Max(target.width, target.height) / 52f, 0.85f, 2.4f);
int projectileIndex = -1;
if (Main.netMode == NetmodeID.Server)
{
SoulHarvest.BroadcastDeathDomainTelegraph(
Player,
target,
remainingFrames,
DeathDomainProgression.SlashCount,
visualMastery,
rotation,
scale);
}
else
{
projectileIndex = Projectile.NewProjectile(
Player.GetSource_Misc("SoulHarvest:DeathDomainTelegraph"),
target.Center,
Vector2.Zero,
ModContent.ProjectileType<DeathDomainHarvestTelegraphProjectile>(),
0,
0f,
Player.whoAmI,
target.whoAmI,
remainingFrames,
DeathDomainProgression.SlashCount + visualMastery * 0.1f);
if (projectileIndex >= 0 && projectileIndex < Main.maxProjectiles)
{
Projectile telegraph = Main.projectile[projectileIndex];
telegraph.rotation = rotation;
telegraph.scale = scale;
}
}
deathDomainPendingCuts[target.whoAmI] = new PendingDeathDomainCut(rotation, projectileIndex);
}
}
private void SpawnDeathDomainHarvestSlash(NPC target, float visualMastery,
bool requiem, float rotation, int deathUltimateActionId = -1)
{
int slashDamage = CalculateDeathDomainSlashDamage(target, DeathDomainProgression);
float scale = MathHelper.Clamp(Math.Max(target.width, target.height) / 52f, 0.85f, 2.4f);
float visualState = visualMastery + (requiem ? 2f : 0f);
if (Main.netMode == NetmodeID.Server)
{
for (int slash = 0; slash < DeathDomainProgression.SlashCount; slash++)
{
pendingDeathDomainStrikes.Add(new PendingDeathDomainStrike(
target.whoAmI,
DeathDomainHarvestSlashProjectile.SlashImpactFrame
+ slash * DeathDomainHarvestSlashProjectile.SlashDelayFrames,
slashDamage,
DeathDomainProgression.LifeStealLevel,
deathUltimateActionId));
}
SoulHarvest.BroadcastDeathDomainHarvestSlash(
Player,
target,
DeathDomainProgression.SlashCount,
visualMastery,
requiem,
rotation,
scale);
return;
}
int projectileIndex = Projectile.NewProjectile(
Player.GetSource_Misc("SoulHarvest:DeathDomain"),
target.Center,
Vector2.Zero,
ModContent.ProjectileType<DeathDomainHarvestSlashProjectile>(),
slashDamage,
0f,
Player.whoAmI,
target.whoAmI,
DeathDomainProgression.SlashCount,
visualState);
if (projectileIndex < 0 || projectileIndex >= Main.maxProjectiles)
return;
Projectile visual = Main.projectile[projectileIndex];
visual.rotation = rotation;
visual.scale = scale;
visual.localAI[1] = DeathDomainProgression.LifeStealLevel;
if (visual.ModProjectile is DeathDomainHarvestSlashProjectile harvest)
harvest.SetDeathUltimateActionId(deathUltimateActionId);
}
private void UpdatePendingDeathDomainStrikes()
{
if (Main.netMode == NetmodeID.MultiplayerClient || pendingDeathDomainStrikes.Count == 0)
return;
for (int index = pendingDeathDomainStrikes.Count - 1; index >= 0; index--)
{
PendingDeathDomainStrike pending = pendingDeathDomainStrikes[index];
int remainingFrames = pending.RemainingFrames - 1;
if (remainingFrames > 0)
{
pendingDeathDomainStrikes[index] = pending with { RemainingFrames = remainingFrames };
continue;
}
pendingDeathDomainStrikes.RemoveAt(index);
ApplyDeathDomainStrike(pending.NpcIndex, pending.Damage,
pending.LifeStealLevel, pending.DeathUltimateActionId);
}
}
internal void ApplyDeathDomainStrike(int npcIndex, int damage,
int lifeStealLevel, int deathUltimateActionId = -1)
{
if (npcIndex < 0 || npcIndex >= Main.maxNPCs)
return;
NPC target = Main.npc[npcIndex];
if (!ReaperTargeting.IsValidWeaponTarget(target))
return;
bool bossOrBossPart = target.boss
|| NPCID.Sets.ShouldBeCountedAsBoss[target.type]
|| target.realLife >= 0 && target.realLife < Main.maxNPCs && Main.npc[target.realLife].boss;
target.GetGlobalNPC<MyGlobalNPC>().ApplyDeathDomainHitStop(target, bossOrBossPart ? 1 : 3);
if (Main.netMode == NetmodeID.MultiplayerClient || damage <= 0)
return;
target.GetGlobalNPC<MyGlobalNPC>().RegisterSickleHit(target, Player.whoAmI);
target.lastInteraction = Player.whoAmI;
NPC.HitInfo harvestHit = new()
{
Damage = damage,
SourceDamage = damage,
HitDirection = 0,
Knockback = 0f,
DamageType = DamageClass.Generic,
Crit = false
};
int lifeBeforeHit = target.life;
Vector2 lifeStealSource = target.Center;
NPC accumulationTarget = target.realLife >= 0
&& target.realLife < Main.maxNPCs
&& Main.npc[target.realLife].active
? Main.npc[target.realLife]
: target;
target.StrikeNPC(harvestHit, fromNet: false, noPlayerInteraction: false);
int actualDamage = Math.Max(0, lifeBeforeHit - Math.Max(0, target.life));
if (deathUltimateActionId >= 0 && actualDamage > 0)
{
accumulationTarget.GetGlobalNPC<ReaperCombatGlobalNPC>()
.RecordDeathUltimateCut(accumulationTarget, Player.whoAmI,
deathUltimateActionId, actualDamage);
}
ApplyDeathReaperLifeSteal(actualDamage, lifeStealSource, lifeStealLevel);
if (Main.netMode == NetmodeID.Server)
{
NetMessage.SendStrikeNPC(target, in harvestHit);
target.netUpdate = true;
}
}
private void ClearDeathDomainTelegraphs()
{
if (deathDomainPendingCuts.Count == 0)
return;
foreach ((int npcIndex, PendingDeathDomainCut pending) in deathDomainPendingCuts)
{
KillDeathDomainTelegraph(pending.ProjectileIndex);
SoulHarvest.BroadcastDeathDomainTelegraphClear(Player, npcIndex);
}
deathDomainPendingCuts.Clear();
}
private void RemoveDeathDomainTelegraph(int npcIndex)
{
if (!deathDomainPendingCuts.Remove(npcIndex, out PendingDeathDomainCut pending))
return;
KillDeathDomainTelegraph(pending.ProjectileIndex);
SoulHarvest.BroadcastDeathDomainTelegraphClear(Player, npcIndex);
}
private void KillDeathDomainTelegraph(int projectileIndex)
{
if (projectileIndex < 0 || projectileIndex >= Main.maxProjectiles)
return;
Projectile projectile = Main.projectile[projectileIndex];
if (projectile.active
&& projectile.owner == Player.whoAmI
&& projectile.ModProjectile is DeathDomainHarvestTelegraphProjectile)
{
projectile.Kill();
}
}
private List<NPC> FindDeathDomainTargets(DeathNecklace necklace, bool descended)
{
List<NPC> targets = [];
HashSet<int> selectedTargets = [];
bool harvestEntireWorld = descended;
float radiusSquared = necklace.DomainRadius * necklace.DomainRadius;
foreach (NPC npc in Main.ActiveNPCs)
{
// chaseable is intentionally not used: vanilla NPCs toggle it during
// movement and transition states, which made dedicated-server harvests
// appear to skip otherwise valid enemies from one cycle to the next.
// Training dummies are excluded from the passive necklace domain even
// though weapon-created Death cuts can explicitly strike them.
if (ReaperTargeting.IsTrainingDummy(npc) || npc.friendly || npc.immortal
|| npc.dontTakeDamage || npc.lifeMax <= 5 || npc.life <= 0)
continue;
NPC target = npc.realLife >= 0 && npc.realLife < Main.maxNPCs && Main.npc[npc.realLife].active
? Main.npc[npc.realLife]
: npc;
if (ReaperTargeting.IsTrainingDummy(target) || target.friendly
|| target.immortal || target.dontTakeDamage || target.lifeMax <= 5 || target.life <= 0
|| selectedTargets.Contains(target.whoAmI)
|| !harvestEntireWorld && Vector2.DistanceSquared(Player.Center, target.Center) > radiusSquared)
{
continue;
}
selectedTargets.Add(target.whoAmI);
targets.Add(target);
}
return targets;
}
private void ApplyDeathDomainForces(DeathNecklace necklace, bool descended)
{
float innerRadius = descended ? 3200f : necklace.DomainRadius;
float outerRadius = descended ? innerRadius : necklace.DomainAttractionRadius;
float innerRadiusSquared = innerRadius * innerRadius;
float outerRadiusSquared = outerRadius * outerRadius;
float attractionWidth = Math.Max(1f, outerRadius - innerRadius);
HashSet<int> affectedTargets = [];
foreach (NPC npc in Main.ActiveNPCs)
{
if (ReaperTargeting.IsTrainingDummy(npc) || npc.friendly || npc.immortal
|| npc.dontTakeDamage || npc.lifeMax <= 5 || npc.life <= 0)
continue;
NPC target = npc.realLife >= 0 && npc.realLife < Main.maxNPCs && Main.npc[npc.realLife].active
? Main.npc[npc.realLife]
: npc;
if (ReaperTargeting.IsTrainingDummy(target) || target.friendly
|| target.immortal || target.dontTakeDamage || target.lifeMax <= 5 || target.life <= 0
|| !affectedTargets.Add(target.whoAmI))
{
continue;
}
Vector2 toDomain = Player.Center - target.Center;
float distanceSquared = toDomain.LengthSquared();
if (distanceSquared > outerRadiusSquared)
continue;
bool bossOrBossPart = target.boss
|| NPCID.Sets.ShouldBeCountedAsBoss[target.type]
|| target.realLife >= 0 && target.realLife < Main.maxNPCs && Main.npc[target.realLife].boss;
float bossScale = bossOrBossPart ? 0.18f : 1f;
float distance = (float)Math.Sqrt(distanceSquared);
bool forceApplied;
if (distanceSquared <= innerRadiusSquared)
{
// Inside the event horizon the flow reverses: enemies are expelled
// from the pale-crimson core. The force reaches zero at the border
// and rises quadratically toward the centre.
Vector2 outward = distance > 0.001f
? -toDomain / distance
: (target.whoAmI * 2.39996323f).ToRotationVector2();
float centralInfluence = 1f - MathHelper.Clamp(distance / innerRadius, 0f, 1f);
centralInfluence *= centralInfluence;
float force = necklace.DomainRepulsionStrength * centralInfluence * bossScale;
float maximumOutwardSpeed = necklace.DomainRepulsionMaximumSpeed * (bossOrBossPart ? 0.35f : 1f);
float currentOutwardSpeed = Vector2.Dot(target.velocity, outward);
float allowedForce = Math.Max(0f, maximumOutwardSpeed - currentOutwardSpeed);
float appliedForce = Math.Min(force, allowedForce);
target.velocity += outward * appliedForce;
forceApplied = appliedForce > 0f;
}
else
{
// Outside the border the black-hole streams draw enemies inward.
float normalizedDistance = MathHelper.Clamp((distance - innerRadius) / attractionWidth, 0f, 1f);
float edgeInfluence = 1f - normalizedDistance;
edgeInfluence = edgeInfluence * edgeInfluence * (3f - 2f * edgeInfluence);
float force = necklace.DomainAttractionStrength * MathHelper.Lerp(0.22f, 1f, edgeInfluence) * bossScale;
float maximumInwardSpeed = necklace.DomainAttractionMaximumSpeed * (bossOrBossPart ? 0.35f : 1f);
Vector2 inward = toDomain / distance;
float currentInwardSpeed = Vector2.Dot(target.velocity, inward);
float allowedForce = Math.Max(0f, maximumInwardSpeed - currentInwardSpeed);
float appliedForce = Math.Min(force, allowedForce);
target.velocity += inward * appliedForce;
forceApplied = appliedForce > 0f;
}
// Periodic authoritative velocity updates are enough for this gentle
// force while avoiding an NPC sync packet on every tick.
if (forceApplied && (Main.GameUpdateCount + (ulong)target.whoAmI) % 10UL == 0UL)
target.netUpdate = true;
}
}
private void ApplyDeathDomainItemAttraction()
{
// The domain owns the whole world while active. Move eligible drops in
// world space on the authority instead of relying on vanilla pickup range;
// this also lets coins and loot travel through terrain rather than becoming
// trapped against a distant wall. Eligible drops are reserved to the
// domain owner while the attraction is active.
Vector2 destination = Player.MountedCenter;
for (int itemIndex = 0; itemIndex < Main.maxItems; itemIndex++)
{
Item item = Main.item[itemIndex];
if (!item.active || item.IsAir || item.stack <= 0
|| !ItemLoader.CanPickup(item, Player)
|| !Player.CanPullItem(item, Player.ItemSpace(item)))
{
continue;
}
// Item.Update continuously chooses a reservation owner. Without
// claiming the drop here, that vanilla pass can immediately hand a
// full-world attraction target to another nearby player and undo the
// domain movement before its owner can collect it.
item.playerIndexTheItemIsReservedFor = Player.whoAmI;
Vector2 toPlayer = destination - item.Center;
float distance = toPlayer.Length();
if (distance <= 1f)
continue;
Vector2 direction = toPlayer / distance;
float step = Math.Min(distance,
MathHelper.Clamp(distance * 0.085f, 18f, 180f));
item.position += direction * step;
item.velocity = direction * Math.Min(24f, 8f + distance * 0.004f);
item.noGrabDelay = 0;
if (distance < 54f)
{
item.Center = destination;
item.velocity = Vector2.Zero;
}
if (Main.netMode == NetmodeID.Server
&& (Main.GameUpdateCount + (ulong)itemIndex) % 6UL == 0UL)
{
NetMessage.SendData(MessageID.SyncItem, -1, -1, null, itemIndex);
}
}
}
private static int CalculateDeathDomainSlashDamage(NPC target, DeathDomainProgression progression)
{
bool bossOrBossPart = target.boss
|| NPCID.Sets.ShouldBeCountedAsBoss[target.type]
|| target.realLife >= 0 && target.realLife < Main.maxNPCs && Main.npc[target.realLife].boss;
float lifeRatio = bossOrBossPart ? progression.BossSlashLifeRatio : progression.NormalSlashLifeRatio;
return Math.Max(1, (int)Math.Ceiling(target.lifeMax * lifeRatio));
}
private void SyncSoulBalance()
{
if (Main.netMode != NetmodeID.Server
|| Player.whoAmI < 0
|| Player.whoAmI >= Netplay.Clients.Length
|| !Netplay.Clients[Player.whoAmI].IsActive)
{
return;
}
SyncPlayer(Player.whoAmI, -1, false);
}
}