using SoulHarvest.Common; using Microsoft.Xna.Framework; using System; using System.IO; using Terraria; using Terraria.ID; using Terraria.ModLoader; namespace SoulHarvest.Projectiles; /// /// Server-authoritative Void ultimate. The gameplay surface is a stationary /// circular tear; its client presentation uses layered continuous noise waves /// instead of a rotating sprite. /// public sealed class ReaperVoidBlackHoleProjectile : ModProjectile { private SickleCombatSnapshot snapshot; private float radius; private float pullRadius; private int lifetime; private int seed; private int age; private bool configured; private bool serverAuthorized; public override string Texture => "Terraria/Images/Projectile_0"; internal static int Spawn(Terraria.DataStructures.IEntitySource source, int owner, in SickleCombatSnapshot snapshot, Vector2 center, float radius, int lifetime, float pullRadius, float damageMultiplier, int actionId) { if (Main.netMode == NetmodeID.MultiplayerClient) return -1; int index = Projectile.NewProjectile(source, center, Vector2.Zero, ModContent.ProjectileType(), Math.Max(1, (int)Math.Round(snapshot.Damage * MathHelper.Clamp(damageMultiplier, 0.05f, 2f))), snapshot.Knockback * 0.12f, owner); if (index < 0 || index >= Main.maxProjectiles) return -1; Projectile projectile = Main.projectile[index]; projectile.originalDamage = snapshot.Damage; projectile.CritChance = 0; int stableSeed = unchecked(actionId * 486187739 + owner * 16777619); if (projectile.ModProjectile is ReaperVoidBlackHoleProjectile blackHole) blackHole.Configure(snapshot, radius, lifetime, pullRadius, stableSeed); projectile.GetGlobalProjectile().ConfigureReaperProjectile( projectile, snapshot, ReaperHitKind.DamageOverTime, 0, 0f, actionId); projectile.localNPCHitCooldown = 18; ReaperProjectileHelper.SyncNewProjectile(projectile); return index; } public override void SetDefaults() { Projectile.width = 4; Projectile.height = 4; Projectile.friendly = true; Projectile.hostile = false; Projectile.tileCollide = false; Projectile.ignoreWater = true; Projectile.penetrate = -1; Projectile.timeLeft = 300; Projectile.netImportant = true; Projectile.DamageType = DamageClass.Melee; Projectile.usesLocalNPCImmunity = true; Projectile.localNPCHitCooldown = 18; } public override bool ShouldUpdatePosition() => false; public override bool? CanDamage() => configured && age >= 5 && Projectile.timeLeft > 18 ? null : false; public override void AI() { if (Main.netMode == NetmodeID.Server && !serverAuthorized) { Projectile.Kill(); return; } if (!configured) return; age++; if (Main.netMode != NetmodeID.MultiplayerClient) PullNearbyEnemies(); Lighting.AddLight(Projectile.Center, new Vector3(0.24f, 0.025f, 0.42f) * MathHelper.Clamp(radius / 220f, 0.6f, 1.35f)); } public override bool? Colliding(Rectangle projHitbox, Rectangle targetHitbox) { float closestX = MathHelper.Clamp(Projectile.Center.X, targetHitbox.Left, targetHitbox.Right); float closestY = MathHelper.Clamp(Projectile.Center.Y, targetHitbox.Top, targetHitbox.Bottom); float damageRadius = radius * 0.78f; return Vector2.DistanceSquared(Projectile.Center, new Vector2(closestX, closestY)) <= damageRadius * damageRadius; } public override bool PreDraw(ref Color lightColor) { if (!configured || Main.dedServ) return false; float reveal = Smooth01(age / 14f); float fade = Smooth01(Projectile.timeLeft / 42f); float expansion = 1f - (float)Math.Pow(1f - reveal, 3f); float breathing = 0.985f + (float)Math.Sin(age * 0.075f + seed * 0.001f) * 0.015f; float visualRadius = radius * expansion * breathing; float opacity = reveal * fade; if (visualRadius <= 1f || opacity <= 0.001f) return false; Vector2 screenCenter = Projectile.Center - Main.screenPosition; DeathDomainPrimitiveTextureSystem.DrawRadialGradient(Main.spriteBatch, screenCenter, visualRadius * 1.55f, new Color(105, 14, 175, 0) * (opacity * 0.22f)); DrawNoiseRipples(visualRadius, opacity); DrawAccretionStreams(visualRadius, opacity); float coreRadius = visualRadius * 0.74f; DeathDomainPrimitiveTextureSystem.DrawSmoothDisc(Main.spriteBatch, screenCenter, coreRadius + 7f, new Color(50, 3, 78, 220) * (opacity * 0.72f)); DeathDomainPrimitiveTextureSystem.DrawSmoothDisc(Main.spriteBatch, screenCenter, coreRadius, new Color(1, 0, 4, 252) * opacity); DeathDomainPrimitiveTextureSystem.DrawRadialGradient(Main.spriteBatch, screenCenter, coreRadius * 0.72f, new Color(35, 3, 62, 0) * (opacity * 0.34f)); float time = age * 0.055f; DrawNoiseBoundary(coreRadius + 4f, 5.5f, 5.2f, opacity * 0.82f, -time * 1.35f, 11.3f); DrawNoiseBoundary(coreRadius, 3.1f, 2.1f, opacity, time * 1.7f, 29.7f); DrawNoiseBoundary(visualRadius, 8.5f, 3.4f, opacity * 0.66f, time, 47.1f); return false; } public override void SendExtraAI(BinaryWriter writer) { writer.Write(configured); if (!configured) return; snapshot.Write(writer); writer.Write(radius); writer.Write(pullRadius); writer.Write((short)Math.Clamp(lifetime, 1, short.MaxValue)); writer.Write(seed); writer.Write((short)Math.Clamp(age, 0, short.MaxValue)); } public override void ReceiveExtraAI(BinaryReader reader) { bool incomingConfigured = reader.ReadBoolean(); if (!incomingConfigured) { if (Main.netMode != NetmodeID.Server) configured = false; return; } SickleCombatSnapshot incomingSnapshot = SickleCombatSnapshot.Read(reader); float incomingRadius = reader.ReadSingle(); float incomingPullRadius = reader.ReadSingle(); int incomingLifetime = reader.ReadInt16(); int incomingSeed = reader.ReadInt32(); int incomingAge = reader.ReadInt16(); if (Main.netMode == NetmodeID.Server) return; snapshot = incomingSnapshot; radius = incomingRadius; pullRadius = incomingPullRadius; lifetime = incomingLifetime; seed = incomingSeed; age = incomingAge; configured = true; } private void Configure(in SickleCombatSnapshot value, float activeRadius, int activeLifetime, float attractionRadius, int shapeSeed) { snapshot = value; radius = MathHelper.Clamp(activeRadius, 72f, 420f); pullRadius = MathHelper.Clamp(attractionRadius, radius, 960f); lifetime = Math.Clamp(activeLifetime, 60, 600); seed = shapeSeed; age = 0; configured = true; serverAuthorized = Main.netMode != NetmodeID.MultiplayerClient; Projectile.timeLeft = lifetime; } private void PullNearbyEnemies() { if (age < 5 || Projectile.timeLeft <= 18) return; foreach (NPC npc in Main.ActiveNPCs) { if (!npc.CanBeChasedBy(Projectile) || npc.boss || npc.realLife >= 0) continue; Vector2 toCenter = Projectile.Center - npc.Center; float distance = toCenter.Length(); if (distance <= 2f || distance >= pullRadius) continue; float influence = 1f - distance / pullRadius; float maximumSpeed = MathHelper.Lerp(1.6f, 9.2f, influence); Vector2 desired = toCenter / distance * maximumSpeed; npc.velocity = Vector2.Lerp(npc.velocity, desired, 0.055f + influence * 0.14f); if (distance < radius * 0.30f) npc.velocity *= 0.72f; if (Main.netMode == NetmodeID.Server && Main.GameUpdateCount % 8 == 0) npc.netUpdate = true; } } private void DrawNoiseRipples(float visualRadius, float opacity) { for (int ripple = 0; ripple < 3; ripple++) { float progress = (age * 0.026f + ripple / 3f) % 1f; float rippleOpacity = (1f - progress) * (1f - progress) * opacity; float rippleRadius = visualRadius * MathHelper.Lerp(1.02f, 1.42f, progress); DrawNoiseBoundary(rippleRadius, 5f + progress * 7f, MathHelper.Lerp(3.2f, 1.1f, progress), rippleOpacity * 0.42f, age * 0.035f * (ripple % 2 == 0 ? 1f : -1f), 71.3f + ripple * 19.7f); } } private void DrawAccretionStreams(float visualRadius, float opacity) { const int arms = 6; const int segments = 22; float rotation = age * 0.018f + seed * 0.00013f; for (int arm = 0; arm < arms; arm++) { float startAngle = rotation + arm * MathHelper.TwoPi / arms; Vector2 previous = Projectile.Center + startAngle.ToRotationVector2() * visualRadius * 1.38f; for (int segment = 1; segment <= segments; segment++) { float progress = segment / (float)segments; float angle = startAngle + progress * 1.32f; float streamRadius = visualRadius * MathHelper.Lerp(1.38f, 0.78f, progress); Vector2 current = Projectile.Center + angle.ToRotationVector2() * streamRadius; float envelope = (float)Math.Sin(progress * MathHelper.Pi); Color color = Color.Lerp(new Color(70, 10, 120, 0), new Color(190, 62, 255, 0), progress); DeathDomainPrimitiveTextureSystem.DrawSmoothWorldLine(previous, current, color * (opacity * envelope * 0.25f), MathHelper.Lerp(5.5f, 1.4f, progress)); previous = current; } } } private void DrawNoiseBoundary(float baseRadius, float amplitude, float width, float opacity, float time, float seedOffset) { if (baseRadius <= 1f || opacity <= 0.001f) return; const int segments = 112; float previousAngle = 0f; float previousRadius = baseRadius + BoundaryNoise(previousAngle, time, seedOffset) * amplitude; Vector2 previous = Projectile.Center + Vector2.UnitX * previousRadius; for (int segment = 1; segment <= segments; segment++) { float angle = MathHelper.TwoPi * segment / segments; float noisyRadius = baseRadius + BoundaryNoise(angle, time, seedOffset) * amplitude; Vector2 current = Projectile.Center + angle.ToRotationVector2() * noisyRadius; float flow = 0.5f + 0.5f * (float)Math.Sin( angle * 4f - time * 2.1f + seedOffset); Color color = Color.Lerp(new Color(78, 10, 132, 0), new Color(218, 82, 255, 0), flow); DeathDomainPrimitiveTextureSystem.DrawSmoothWorldLine(previous, current, color * opacity, width * (0.76f + flow * 0.38f)); previous = current; } } private static float BoundaryNoise(float angle, float time, float noiseSeed) { float low = (float)Math.Sin(angle * 3f + time * 0.83f + noiseSeed) * 0.49f; float middle = (float)Math.Sin(angle * 7f - time * 1.27f + noiseSeed * 1.71f) * 0.29f; float high = (float)Math.Sin(angle * 13f + time * 1.81f + noiseSeed * 0.39f) * 0.15f; float grain = (float)Math.Sin(angle * 29f - time * 2.37f + noiseSeed * 2.13f) * 0.07f; return low + middle + high + grain; } private static float Smooth01(float value) { value = MathHelper.Clamp(value, 0f, 1f); return value * value * (3f - 2f * value); } }