using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using SoulHarvest.Common; using SoulHarvest.Items; using System; using System.IO; using Terraria; using Terraria.ID; using Terraria.ModLoader; namespace SoulHarvest.Projectiles; /// /// Server-authored, non-damaging form-switch clock. The target form is not /// committed to character progression until this projectile completes, so its /// synchronized timer is also the visual and gameplay definition of “assembled”. /// public sealed class ReaperFormAssemblyProjectile : ModProjectile { private ReaperFormId targetForm; private ReaperStage targetStage; private int duration; private int timer; private byte deathRangeLevel; private bool configured; private bool serverAuthorized; private float weaponAngle; private int facing = 1; internal ReaperFormId TargetForm => targetForm; internal ReaperStage TargetStage => targetStage; internal int Duration => duration; internal int Elapsed => timer; internal bool AssemblyComplete => configured && timer >= duration; public override string Texture => "Terraria/Images/Projectile_0"; internal static bool TryGetActive(Player player, out ReaperFormAssemblyProjectile? assembly) { int type = ModContent.ProjectileType(); foreach (Projectile projectile in Main.ActiveProjectiles) { if (projectile.owner == player.whoAmI && projectile.type == type && projectile.ModProjectile is ReaperFormAssemblyProjectile candidate) { assembly = candidate; return true; } } assembly = null; return false; } internal static int Spawn(Player player, ReaperFormId form, ReaperStage stage, int assemblyFrames, int rangeLevel) { if (Main.netMode == NetmodeID.MultiplayerClient) return -1; int index = Projectile.NewProjectile( player.GetSource_Misc("SoulHarvest:ReaperFormAssembly"), player.MountedCenter, Vector2.Zero, ModContent.ProjectileType(), 0, 0f, player.whoAmI); if (index < 0 || index >= Main.maxProjectiles) return -1; Projectile projectile = Main.projectile[index]; if (projectile.ModProjectile is not ReaperFormAssemblyProjectile assembly) { projectile.Kill(); return -1; } assembly.Configure(form, stage, assemblyFrames, rangeLevel, authorizeServer: true); ReaperProjectileHelper.SyncNewProjectile(projectile); return index; } public override void SetDefaults() { Projectile.width = 2; Projectile.height = 2; Projectile.friendly = false; Projectile.hostile = false; Projectile.tileCollide = false; Projectile.ignoreWater = true; Projectile.penetrate = -1; Projectile.timeLeft = 360; Projectile.hide = true; Projectile.netImportant = true; } public override bool ShouldUpdatePosition() => false; public override bool? CanDamage() => false; public override void AI() { if (Main.netMode == NetmodeID.Server && !serverAuthorized) { Projectile.Kill(); return; } if (!configured || Projectile.owner < 0 || Projectile.owner >= Main.maxPlayers) { return; } Player player = Main.player[Projectile.owner]; if (!player.active || player.dead || player.HeldItem.ModItem is not NormalSickle) { Projectile.Kill(); return; } Projectile.Center = player.MountedCenter; Projectile.timeLeft = 2; facing = player.direction == 0 ? 1 : player.direction; float aimAngle = facing > 0 ? 0f : MathHelper.Pi; weaponAngle = aimAngle - 1.45f * facing; player.ChangeDir(facing); player.heldProj = Projectile.whoAmI; player.itemTime = Math.Max(player.itemTime, 2); player.itemAnimation = Math.Max(player.itemAnimation, 2); player.SetCompositeArmFront(true, Player.CompositeArmStretchAmount.Full, weaponAngle - MathHelper.PiOver2); player.itemRotation = MathHelper.WrapAngle(weaponAngle); if (timer < duration) { timer++; if (timer == duration) Projectile.netUpdate = true; return; } if (Main.netMode == NetmodeID.MultiplayerClient) { // The owner client can reach the last visual tick a frame before the // server. Never let that predicted copy send a projectile kill back // and cancel the authoritative commit; hold the completed weapon // until the server changes the form and removes the controller. timer = duration; return; } bool completed = player.GetModPlayer() .TryCompleteReaperFormAssembly(Projectile.whoAmI, targetForm); #if DEBUG Mod.Logger.Info( $"SOULHARVEST_FORM_ASSEMBLY: complete player={player.whoAmI} target={targetForm} elapsed={timer}/{duration} committed={completed}"); #endif Projectile.Kill(); } public override bool PreDraw(ref Color lightColor) { if (Main.dedServ || !configured || Projectile.owner < 0 || Projectile.owner >= Main.maxPlayers) { return false; } Player player = Main.player[Projectile.owner]; if (!player.active) return false; Texture2D texture = ModContent.Request( ReaperCombatRegistry.GetTexturePath(targetForm, targetStage)).Value; Vector2 normalizedAnchor = ReaperCombatRegistry.GetHandleAnchor( targetForm, targetStage); Vector2 anchor = new(texture.Width * normalizedAnchor.X, texture.Height * normalizedAnchor.Y); SpriteEffects effects = facing < 0 ? SpriteEffects.FlipVertically : SpriteEffects.None; if ((effects & SpriteEffects.FlipVertically) != 0) anchor.Y = texture.Height - anchor.Y; int stageNumber = targetForm == ReaperFormId.Base ? 0 : targetForm == ReaperFormId.Death ? 3 : (int)targetStage; float scale = targetForm == ReaperFormId.Death ? ReaperCombatRegistry.DeathWeaponDrawScale * ReaperDefinitions.GetDeathRangeMultiplier(deathRangeLevel) : 0.86f + stageNumber * 0.045f; float rotation = weaponAngle + ReaperCombatRegistry.GetTextureRotationCorrection(targetForm, targetStage); DeathSickleAssemblyTextureSystem.Draw(texture, player.MountedCenter, rotation, anchor, scale, effects, lightColor, timer, duration, Projectile.identity * 47 + (int)targetForm * 1291, targetForm == ReaperFormId.Death); return false; } public override void SendExtraAI(BinaryWriter writer) { writer.Write(configured); if (!configured) return; writer.Write((byte)targetForm); writer.Write((byte)targetStage); writer.Write((short)Math.Clamp(duration, 1, short.MaxValue)); writer.Write((short)Math.Clamp(timer, 0, short.MaxValue)); writer.Write(deathRangeLevel); } public override void ReceiveExtraAI(BinaryReader reader) { bool incomingConfigured = reader.ReadBoolean(); if (!incomingConfigured) { if (Main.netMode != NetmodeID.Server) configured = false; return; } ReaperFormId incomingForm = (ReaperFormId)reader.ReadByte(); ReaperStage incomingStage = (ReaperStage)reader.ReadByte(); int incomingDuration = Math.Max(1, (int)reader.ReadInt16()); int incomingTimer = Math.Clamp(reader.ReadInt16(), 0, incomingDuration); byte incomingRangeLevel = reader.ReadByte(); if (Main.netMode == NetmodeID.Server) return; Configure(incomingForm, incomingStage, incomingDuration, incomingRangeLevel, authorizeServer: false); timer = incomingTimer; } public override void OnKill(int timeLeft) { #if DEBUG if (Main.netMode != NetmodeID.MultiplayerClient) { string ownerState = Projectile.owner >= 0 && Projectile.owner < Main.maxPlayers ? $"active={Main.player[Projectile.owner].active} dead={Main.player[Projectile.owner].dead} held={Main.player[Projectile.owner].HeldItem.type}" : "owner-invalid"; Mod.Logger.Info( $"SOULHARVEST_FORM_ASSEMBLY: killed player={Projectile.owner} target={targetForm} elapsed={timer}/{duration} authorized={serverAuthorized} configured={configured} {ownerState}"); } #endif if (Projectile.owner < 0 || Projectile.owner >= Main.maxPlayers) return; Player player = Main.player[Projectile.owner]; if (player.heldProj == Projectile.whoAmI) player.heldProj = -1; } private void Configure(ReaperFormId form, ReaperStage stage, int assemblyFrames, int rangeLevel, bool authorizeServer) { targetForm = Enum.IsDefined(form) ? form : ReaperFormId.Base; targetStage = stage is >= ReaperStage.StageI and <= ReaperStage.StageIII ? stage : ReaperStage.StageI; duration = Math.Clamp(assemblyFrames, 1, 600); deathRangeLevel = (byte)Math.Clamp(rangeLevel, 0, byte.MaxValue); timer = 0; configured = true; serverAuthorized = authorizeServer; // Only the authoritative creator publishes state. Marking the replicated // owner copy dirty echoes a late spawn packet back after the server has // already committed and killed it, creating a short-lived ghost. if (authorizeServer) Projectile.netUpdate = true; } }