using SoulHarvest.Items; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using System; using Terraria; using Terraria.GameContent; using Terraria.ModLoader; namespace SoulHarvest.Common; [Autoload(Side = ModSide.Client)] internal class DeathDomainVisualSystem : ModSystem { private const float OpenAnimationSpeed = 1f / 42f; private const float CloseAnimationSpeed = 1f / 42f; private const float DescentAnimationSpeed = 1f / 52f; private const float DepartureAnimationSpeed = 1f / 42f; private readonly float[] domainAnimation = new float[Main.maxPlayers]; private readonly float[] descentAnimation = new float[Main.maxPlayers]; private readonly float[] cachedRadius = new float[Main.maxPlayers]; private readonly int[] cachedMastery = new int[Main.maxPlayers]; private readonly bool[] cachedFullScreen = new bool[Main.maxPlayers]; public override void Load() { On_Main.DoDraw_WallsTilesNPCs += DrawDomainBackdropsBeforeWallsAndTiles; } public override void Unload() { On_Main.DoDraw_WallsTilesNPCs -= DrawDomainBackdropsBeforeWallsAndTiles; } public override void PostUpdateEverything() { if (Main.gameMenu) { ClearAnimationState(); return; } for (int index = 0; index < Main.maxPlayers; index++) { Player player = Main.player[index]; if (!player.active) { domainAnimation[index] = 0f; descentAnimation[index] = 0f; cachedRadius[index] = 0f; cachedMastery[index] = 0; cachedFullScreen[index] = false; continue; } MyPlayer data = player.GetModPlayer(); DeathNecklace? necklace = data.ActiveDeathNecklace; if (necklace is not null) { cachedRadius[index] = necklace.DomainRadius; cachedMastery[index] = 5 + (int)MathF.Round(data.DeathDomainProgression.VisualMastery * 70f); } bool enabled = !player.dead && necklace is not null && data.DeathDomainEnabled; bool descended = enabled && data.IsDeathDomainDescended(necklace); cachedFullScreen[index] = descended; float speed = enabled ? OpenAnimationSpeed : CloseAnimationSpeed; domainAnimation[index] = MathHelper.Clamp( domainAnimation[index] + (enabled ? speed : -speed), 0f, 1f); float descentSpeed = descended ? DescentAnimationSpeed : DepartureAnimationSpeed; descentAnimation[index] = MathHelper.Clamp( descentAnimation[index] + (descended ? descentSpeed : -descentSpeed), 0f, 1f); } } public override void OnWorldUnload() { ClearAnimationState(); } public override void PostDrawTiles() { Player localPlayer = Main.LocalPlayer; if (Main.gameMenu || !localPlayer.active) return; bool anyVisibleDomain = false; for (int index = 0; index < Main.maxPlayers; index++) { if (domainAnimation[index] > 0.001f) { anyVisibleDomain = true; break; } } if (!anyVisibleDomain) return; SpriteBatch batch = Main.spriteBatch; batch.Begin( SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.None, RasterizerState.CullNone, null, Main.GameViewMatrix.TransformationMatrix); for (int index = 0; index < Main.maxPlayers; index++) { Player player = Main.player[index]; float animation = domainAnimation[index]; if (!player.active || animation <= 0.001f) continue; float radius = cachedRadius[index]; int mastery = cachedMastery[index]; bool fullScreen = cachedFullScreen[index]; if (radius <= 0f) radius = 160f; Vector2 screenCenter = player.Center - Main.screenPosition; if (!fullScreen && (screenCenter.X < -radius || screenCenter.X > Main.screenWidth + radius || screenCenter.Y < -radius || screenCenter.Y > Main.screenHeight + radius)) { continue; } if (!fullScreen) DrawDomainRing(batch, screenCenter, radius, mastery, animation, drawBackdrop: false); else DrawFullScreenDomain(batch, screenCenter, radius, mastery, animation); float descent = descentAnimation[index]; if (descent > 0.001f && descent < 0.999f) { DrawManifestationWave( batch, screenCenter, radius, GetMasteryProgress(mastery), GetDomainApertureReveal(animation), descent, drawBackdrop: false, drawBoundary: true); } } batch.End(); } private static void DrawDomainRing( SpriteBatch batch, Vector2 center, float targetRadius, int mastery, float animation, bool drawBackdrop = true) { float time = Main.GlobalTimeWrappedHourly; float progress = MathHelper.SmoothStep(0f, 1f, animation); float reveal = GetDomainApertureReveal(animation); float rangeProgress = MathHelper.Clamp( (targetRadius - 160f) / (DeathNecklace.RadiusGrowthPerLevel * (DeathNecklace.MaxCoreLevel - 1)), 0f, 1f); float manifestation = MathHelper.Lerp(0.38f, 1f, rangeProgress); float manifestedReveal = reveal * manifestation; float transition = (float)Math.Sqrt(Math.Max(0f, (float)Math.Sin(animation * MathHelper.Pi))); float overshoot = 1f + (float)Math.Sin(progress * MathHelper.Pi) * 0.028f; float radius = MathHelper.Lerp(8f, targetRadius, progress) * overshoot; float pulse = (0.82f + (float)Math.Sin(time * 3.2f) * 0.12f) * manifestedReveal; float masteryProgress = GetMasteryProgress(mastery); DrawBlackHoleField(batch, center, radius, masteryProgress, pulse, time, manifestedReveal, drawBackdrop); DrawNoiseBoundary(batch, center, radius + 3f, time, 0.4f, 12f + transition * 28f, 9f + transition * 3f, (0.18f + transition * 0.16f) * pulse, masteryProgress, gaps: true); DrawNoiseBoundary(batch, center, radius, time, 1.7f, 9f + transition * 20f, 5f + transition * 1.8f, (0.42f + transition * 0.34f) * pulse, masteryProgress, gaps: true); DrawNoiseBoundary(batch, center, radius - 2f, time, 3.3f, 7f + transition * 14f, 2.7f + masteryProgress + transition * 1.6f, (0.94f + transition * 0.22f) * pulse, masteryProgress, gaps: false); DrawNoiseBoundary(batch, center, radius - 7f, -time, 5.1f, 5f, 1.4f, 0.58f * pulse, masteryProgress, gaps: true); DrawDomainRippleWaves(batch, center, radius, time, masteryProgress, pulse); } private static void DrawBlackHoleField( SpriteBatch batch, Vector2 center, float radius, float mastery, float pulse, float time, float reveal, bool drawBackdrop) { float coreGap = MathHelper.Lerp(22f, 14f, mastery); float coreRadius = radius > coreGap + 6f ? radius - coreGap : radius * 0.58f; for (int arm = 0; arm < 5; arm++) DrawAccretionSpiral(batch, center, coreRadius, Math.Max(coreRadius + 2f, radius - 3f), time, arm, mastery, pulse); float gap = Math.Max(2f, radius - coreRadius); DrawNoiseBoundary(batch, center, coreRadius + gap * 0.72f, -time * 1.35f, 9.4f, 4.5f, 7f, 0.18f * pulse, mastery, gaps: true); DrawNoiseBoundary(batch, center, coreRadius + gap * 0.34f, time * 1.8f, 12.8f, 2.8f, 3.8f, 0.78f * pulse, mastery, gaps: true); float coreNoiseTime = time * 0.8f; const float coreNoiseSeed = 16.2f; float coreNoiseAmplitude = Math.Min(coreRadius * 0.18f, MathHelper.Lerp(5f, 10f, mastery) * MathHelper.Lerp(0.35f, 1f, reveal)); if (drawBackdrop) { DrawClippedDeathDomainBackdrop( batch, center, coreRadius, coreNoiseTime, coreNoiseSeed, coreNoiseAmplitude, mastery, reveal); } // The old scan-band fill stamped hundreds of horizontal rounded strips // over the centre and produced the visible television-line pattern. A // single antialiased disc supplies the same restrained shadow without any // row seams; the animated noisy boundary still defines its organic edge. DeathDomainPrimitiveTextureSystem.DrawSmoothDisc( batch, center, coreRadius, new Color(3, 0, 10) * (MathHelper.Lerp(0.08f, 0.26f, mastery) * reveal)); DeathDomainPrimitiveTextureSystem.DrawRadialGradient( batch, center, coreRadius * MathHelper.Lerp(0.48f, 0.58f, mastery), new Color(255, 58, 84) * (MathHelper.Lerp(0.16f, 0.24f, mastery) * pulse)); DrawOutwardPressureWaves(batch, center, coreRadius, time, mastery, pulse); DrawNoiseBoundary(batch, center, coreRadius, coreNoiseTime, coreNoiseSeed, coreNoiseAmplitude, 2.2f, 0.76f * pulse, mastery, gaps: false); DrawInwardSuctionWaves(batch, center, radius, coreRadius, time, mastery, pulse); } private static void DrawClippedDeathDomainBackdrop( SpriteBatch batch, Vector2 center, float baseRadius, float noiseTime, float noiseSeed, float noiseAmplitude, float mastery, float reveal) { const int boundarySegments = 192; float preferredBandHeight = MathHelper.Clamp(baseRadius / 280f, 2f, 8f); Span boundary = stackalloc Vector2[boundarySegments]; float extent = 0f; for (int index = 0; index < boundarySegments; index++) { float angle = MathHelper.TwoPi * index / boundarySegments; float radius = Math.Max(1f, baseRadius + BoundaryNoise(angle, noiseTime, noiseSeed) * noiseAmplitude); boundary[index] = angle.ToRotationVector2() * radius; extent = Math.Max(extent, Math.Abs(boundary[index].Y)); } int bands = Math.Max(1, (int)Math.Ceiling(extent * 2f / preferredBandHeight)); float bandHeight = extent * 2f / bands; DeathDomainBackdropTextureSystem.GetExpandedDomainDrawBounds( out Vector2 expandedDestination, out float expandedDestinationWidth, out float expandedDestinationHeight); float expandedDestinationRight = expandedDestination.X + expandedDestinationWidth; float expandedDestinationBottom = expandedDestination.Y + expandedDestinationHeight; // Draw one complete layer at a time. This retains proper alpha compositing // while allowing SpriteBatch to group hundreds of clipped scan bands into // only three texture batches instead of switching textures for every row. for (int layer = 0; layer < 3; layer++) { float opacity = layer switch { 0 => MathHelper.Lerp(0.88f, 1f, mastery) * reveal, 1 => MathHelper.Lerp(0.72f, 0.9f, mastery) * reveal, _ => MathHelper.Lerp(0.78f, 0.96f, mastery) * reveal }; Texture2D texture = DeathDomainBackdropTextureSystem.GetLayer(layer); DeathDomainBackdropTextureSystem.GetExpandedDomainSourceFrame(layer, out float sourceOffsetX, out float sourceOffsetY, out float viewWidth, out float viewHeight); for (int index = 0; index < bands; index++) { float scanY = -extent + (index + 0.5f) * bandHeight; float left = float.MaxValue; float right = float.MinValue; for (int edge = 0; edge < boundarySegments; edge++) { Vector2 start = boundary[edge]; Vector2 end = boundary[(edge + 1) % boundarySegments]; if ((start.Y <= scanY && end.Y > scanY) || (end.Y <= scanY && start.Y > scanY)) { float amount = (scanY - start.Y) / (end.Y - start.Y); float intersectionX = MathHelper.Lerp(start.X, end.X, amount); left = Math.Min(left, intersectionX); right = Math.Max(right, intersectionX); } } if (left >= right) continue; float drawLeft = Math.Max(expandedDestination.X, center.X + left); float drawRight = Math.Min(expandedDestinationRight, center.X + right); float drawTop = Math.Max(expandedDestination.Y, center.Y + scanY - bandHeight * 0.5f); float drawBottom = Math.Min(expandedDestinationBottom, center.Y + scanY + bandHeight * 0.5f); if (drawLeft >= drawRight || drawTop >= drawBottom) continue; float normalizedLeft = (drawLeft - expandedDestination.X) / expandedDestinationWidth; float normalizedRight = (drawRight - expandedDestination.X) / expandedDestinationWidth; float normalizedTop = (drawTop - expandedDestination.Y) / expandedDestinationHeight; float normalizedBottom = (drawBottom - expandedDestination.Y) / expandedDestinationHeight; DrawBackdropBand( batch, texture, new Vector2(drawLeft, drawTop), drawRight - drawLeft, drawBottom - drawTop, sourceOffsetX + normalizedLeft * viewWidth, sourceOffsetY + normalizedTop * viewHeight, Math.Max(0.5f, (normalizedRight - normalizedLeft) * viewWidth), Math.Max(0.5f, (normalizedBottom - normalizedTop) * viewHeight), opacity); } } } private static void DrawBackdropBand( SpriteBatch batch, Texture2D texture, Vector2 destination, float destinationWidth, float destinationHeight, float sourceX, float sourceY, float sourceWidth, float sourceHeight, float opacity) { if (destinationWidth <= 0.05f || destinationHeight <= 0.05f || opacity <= 0.001f) return; float clampedSourceY = MathHelper.Clamp(sourceY, 0f, texture.Height - 0.001f); float clampedSourceHeight = Math.Min(sourceHeight, texture.Height - clampedSourceY); if (clampedSourceHeight <= 0.001f) return; float remainingSourceWidth = sourceWidth; float consumedSourceWidth = 0f; float wrappedSourceX = PositiveModulo(sourceX, texture.Width); while (remainingSourceWidth > 0.001f) { float sourceWidthPart = Math.Min(remainingSourceWidth, texture.Width - wrappedSourceX); float destinationWidthPart = destinationWidth * sourceWidthPart / sourceWidth; float destinationX = destination.X + destinationWidth * consumedSourceWidth / sourceWidth; DrawBackdropPart( batch, texture, new Vector2(destinationX, destination.Y), destinationWidthPart, destinationHeight, wrappedSourceX, clampedSourceY, sourceWidthPart, clampedSourceHeight, opacity); remainingSourceWidth -= sourceWidthPart; consumedSourceWidth += sourceWidthPart; wrappedSourceX = 0f; } } private static void DrawBackdropPart( SpriteBatch batch, Texture2D texture, Vector2 destination, float destinationWidth, float destinationHeight, float sourceX, float sourceY, float sourceWidth, float sourceHeight, float opacity) { int sourceLeft = Math.Clamp((int)Math.Floor(sourceX), 0, texture.Width - 1); int sourceTop = Math.Clamp((int)Math.Floor(sourceY), 0, texture.Height - 1); int sourceRight = Math.Clamp((int)Math.Ceiling(sourceX + sourceWidth), sourceLeft + 1, texture.Width); int sourceBottom = Math.Clamp((int)Math.Ceiling(sourceY + sourceHeight), sourceTop + 1, texture.Height); Rectangle source = new(sourceLeft, sourceTop, sourceRight - sourceLeft, sourceBottom - sourceTop); batch.Draw( texture, destination, source, Color.White * opacity, 0f, Vector2.Zero, new Vector2(destinationWidth / source.Width, destinationHeight / source.Height), SpriteEffects.None, 0f); } private static void DrawFullScreenDomain( SpriteBatch batch, Vector2 center, float playerDomainRadius, int mastery, float animation) { // Full-screen mastery removes the circular crop from the domain scene. The // player's event horizon is still the same max-level field, rather than a // circle enlarged until it reaches the viewport corners. DrawDomainRing(batch, center, playerDomainRadius, mastery, animation, drawBackdrop: false); } private void DrawDomainBackdropsBeforeWallsAndTiles(On_Main.orig_DoDraw_WallsTilesNPCs orig, Main self) { DrawDomainBackdrops(); orig(self); } private void DrawDomainBackdrops() { if (Main.gameMenu || !Main.LocalPlayer.active) return; bool anyCompletedFullScreen = false; bool anyClipped = false; float fullScreenMastery = 0f; float fullScreenReveal = 0f; for (int index = 0; index < Main.maxPlayers; index++) { if (domainAnimation[index] <= 0.001f || !Main.player[index].active) continue; float descent = descentAnimation[index]; if (descent >= 0.999f) { anyCompletedFullScreen = true; fullScreenMastery = Math.Max(fullScreenMastery, GetMasteryProgress(cachedMastery[index])); fullScreenReveal = Math.Max( fullScreenReveal, MathHelper.SmoothStep(0f, 1f, domainAnimation[index])); } if (descent < 0.999f) { anyClipped = true; } } if (!anyCompletedFullScreen && !anyClipped) return; // This hook runs during final world composition, after Terraria has drawn // (or composited) its background and before it starts walls and tiles. // Reuse the active world SpriteBatch so the domain is never captured in // the scrolling background render target. SpriteBatch batch = Main.spriteBatch; if (anyClipped) { for (int index = 0; index < Main.maxPlayers; index++) { Player player = Main.player[index]; float animation = domainAnimation[index]; if (descentAnimation[index] >= 0.999f || animation <= 0.001f || !player.active) continue; float targetRadius = Math.Max(160f, cachedRadius[index]); Vector2 center = player.Center - Main.screenPosition; if (center.X < -targetRadius || center.X > Main.screenWidth + targetRadius || center.Y < -targetRadius || center.Y > Main.screenHeight + targetRadius) { continue; } GetAnimatedBackdropGeometry(targetRadius, cachedMastery[index], animation, out float coreRadius, out float noiseTime, out float noiseAmplitude, out float masteryProgress, out float reveal); DrawClippedDeathDomainBackdrop( batch, center, coreRadius, noiseTime, 16.2f, noiseAmplitude, masteryProgress, reveal); DrawTransientBackdropBlackHoles( batch, center, new Vector2(coreRadius), masteryProgress, reveal, coreRadius, Main.worldID + index * 101); } } if (anyCompletedFullScreen) { // The backdrop is camera-relative, so multiple manifested domains share // one strongest presentation instead of darkening the scene repeatedly. DrawFullScreenBackdrop(batch, fullScreenMastery, fullScreenReveal); } for (int index = 0; index < Main.maxPlayers; index++) { Player player = Main.player[index]; float descent = descentAnimation[index]; if (!player.active || domainAnimation[index] <= 0.001f || descent <= 0.001f || descent >= 0.999f) { continue; } if (!anyCompletedFullScreen) { DrawManifestationWave( batch, player.Center - Main.screenPosition, Math.Max(160f, cachedRadius[index]), GetMasteryProgress(cachedMastery[index]), GetDomainApertureReveal(domainAnimation[index]), descent, drawBackdrop: true, drawBoundary: false); } } } private static void GetAnimatedBackdropGeometry( float targetRadius, int mastery, float animation, out float coreRadius, out float noiseTime, out float noiseAmplitude, out float masteryProgress, out float reveal) { float progress = MathHelper.SmoothStep(0f, 1f, animation); reveal = GetDomainApertureReveal(animation); float rangeProgress = MathHelper.Clamp( (targetRadius - 160f) / (DeathNecklace.RadiusGrowthPerLevel * (DeathNecklace.MaxCoreLevel - 1)), 0f, 1f); float manifestedReveal = reveal * MathHelper.Lerp(0.38f, 1f, rangeProgress); float overshoot = 1f + (float)Math.Sin(progress * MathHelper.Pi) * 0.028f; float radius = MathHelper.Lerp(8f, targetRadius, progress) * overshoot; masteryProgress = GetMasteryProgress(mastery); float coreGap = MathHelper.Lerp(22f, 14f, masteryProgress); coreRadius = radius > coreGap + 6f ? radius - coreGap : radius * 0.58f; noiseTime = Main.GlobalTimeWrappedHourly * 0.8f; noiseAmplitude = Math.Min( coreRadius * 0.18f, MathHelper.Lerp(5f, 10f, masteryProgress) * MathHelper.Lerp(0.35f, 1f, manifestedReveal)); reveal = manifestedReveal; } private static float GetDomainApertureReveal(float animation) { // The domain material becomes opaque almost immediately behind the moving // noisy edge. Opacity only falls once the contracting aperture is nearly // closed, so opening and closing read as spatial sweeps instead of fades. return MathHelper.SmoothStep(0f, 1f, MathHelper.Clamp(animation / 0.1f, 0f, 1f)); } private static void DrawManifestationWave( SpriteBatch batch, Vector2 center, float finiteRadius, float mastery, float domainReveal, float progress, bool drawBackdrop, bool drawBoundary) { DeathDomainBackdropTextureSystem.GetExpandedDomainDrawBounds( out Vector2 destination, out float destinationWidth, out float destinationHeight); Vector2 topRight = destination + Vector2.UnitX * destinationWidth; Vector2 bottomLeft = destination + Vector2.UnitY * destinationHeight; Vector2 bottomRight = destination + new Vector2(destinationWidth, destinationHeight); float screenRadius = Math.Max( Math.Max(Vector2.Distance(center, destination), Vector2.Distance(center, topRight)), Math.Max(Vector2.Distance(center, bottomLeft), Vector2.Distance(center, bottomRight))); float expansion = MathHelper.SmoothStep(0f, 1f, progress); float radius = MathHelper.Lerp(Math.Max(24f, finiteRadius), screenRadius + 72f, expansion); float time = Main.GlobalTimeWrappedHourly; float noiseAmplitude = MathHelper.Lerp(46f, 24f, expansion) * (0.88f + (float)Math.Sin(time * 4.1f) * 0.12f); float backdropReveal = MathHelper.SmoothStep(0f, 1f, MathHelper.Clamp(progress / 0.14f, 0f, 1f)) * domainReveal; if (drawBackdrop) { DrawClippedFullScreenBackdrop( batch, center, radius, time * 1.15f, 31.7f, noiseAmplitude, mastery, backdropReveal, destination, destinationWidth, destinationHeight); } if (drawBoundary) { float edgeFade = (float)Math.Sin(progress * MathHelper.Pi); edgeFade = (float)Math.Sqrt(Math.Max(0f, edgeFade)) * domainReveal; DrawNoiseBoundary(batch, center, radius + 8f, -time * 1.38f, 47.3f, noiseAmplitude * 1.16f, 10f, edgeFade * 0.22f, mastery, gaps: true); DrawNoiseBoundary(batch, center, radius, time * 1.15f, 31.7f, noiseAmplitude, 5.5f, edgeFade * 0.92f, mastery, gaps: false); DrawNoiseBoundary(batch, center, radius - 10f, time * 1.72f, 63.1f, noiseAmplitude * 0.58f, 2.4f, edgeFade * 0.48f, mastery, gaps: true); } } private static void DrawClippedFullScreenBackdrop( SpriteBatch batch, Vector2 center, float baseRadius, float noiseTime, float noiseSeed, float noiseAmplitude, float mastery, float reveal, Vector2 destination, float destinationWidth, float destinationHeight) { const int boundarySegments = 192; const int maximumBands = 1024; Span boundary = stackalloc Vector2[boundarySegments]; float extent = 0f; for (int index = 0; index < boundarySegments; index++) { float angle = MathHelper.TwoPi * index / boundarySegments; float radius = Math.Max(1f, baseRadius + BoundaryNoise(angle, noiseTime, noiseSeed) * noiseAmplitude); boundary[index] = angle.ToRotationVector2() * radius; extent = Math.Max(extent, Math.Abs(boundary[index].Y)); } int bands = Math.Clamp((int)Math.Ceiling(extent * 2f / 8f), 1, maximumBands); float bandHeight = extent * 2f / bands; Span leftEdges = stackalloc float[bands]; Span rightEdges = stackalloc float[bands]; for (int band = 0; band < bands; band++) { float scanY = -extent + (band + 0.5f) * bandHeight; float left = float.MaxValue; float right = float.MinValue; for (int edge = 0; edge < boundarySegments; edge++) { Vector2 start = boundary[edge]; Vector2 end = boundary[(edge + 1) % boundarySegments]; if ((start.Y <= scanY && end.Y > scanY) || (end.Y <= scanY && start.Y > scanY)) { float amount = (scanY - start.Y) / (end.Y - start.Y); float intersectionX = MathHelper.Lerp(start.X, end.X, amount); left = Math.Min(left, intersectionX); right = Math.Max(right, intersectionX); } } leftEdges[band] = left; rightEdges[band] = right; } float destinationRight = destination.X + destinationWidth; float destinationBottom = destination.Y + destinationHeight; for (int layer = 0; layer < 3; layer++) { float opacity = layer switch { 0 => MathHelper.Lerp(0.88f, 1f, mastery) * reveal, 1 => MathHelper.Lerp(0.72f, 0.9f, mastery) * reveal, _ => MathHelper.Lerp(0.78f, 0.96f, mastery) * reveal }; Texture2D texture = DeathDomainBackdropTextureSystem.GetLayer(layer); DeathDomainBackdropTextureSystem.GetExpandedDomainSourceFrame(layer, out float sourceOffsetX, out float sourceOffsetY, out float viewWidth, out float viewHeight); for (int band = 0; band < bands; band++) { float left = leftEdges[band]; float right = rightEdges[band]; if (left >= right) continue; float scanY = -extent + (band + 0.5f) * bandHeight; float drawLeft = Math.Max(destination.X, center.X + left); float drawRight = Math.Min(destinationRight, center.X + right); float drawTop = Math.Max(destination.Y, center.Y + scanY - bandHeight * 0.5f); float drawBottom = Math.Min(destinationBottom, center.Y + scanY + bandHeight * 0.5f); if (drawLeft >= drawRight || drawTop >= drawBottom) continue; float normalizedLeft = (drawLeft - destination.X) / destinationWidth; float normalizedRight = (drawRight - destination.X) / destinationWidth; float normalizedTop = (drawTop - destination.Y) / destinationHeight; float normalizedBottom = (drawBottom - destination.Y) / destinationHeight; DrawBackdropBand( batch, texture, new Vector2(drawLeft, drawTop), drawRight - drawLeft, drawBottom - drawTop, sourceOffsetX + normalizedLeft * viewWidth, sourceOffsetY + normalizedTop * viewHeight, Math.Max(0.5f, (normalizedRight - normalizedLeft) * viewWidth), Math.Max(0.5f, (normalizedBottom - normalizedTop) * viewHeight), opacity); } } } private static void DrawFullScreenBackdrop(SpriteBatch batch, float mastery, float reveal) { DeathDomainBackdropTextureSystem.GetExpandedDomainDrawBounds( out Vector2 destination, out float destinationWidth, out float destinationHeight); for (int layer = 0; layer < 3; layer++) { float opacity = layer switch { 0 => MathHelper.Lerp(0.88f, 1f, mastery) * reveal, 1 => MathHelper.Lerp(0.72f, 0.9f, mastery) * reveal, _ => MathHelper.Lerp(0.78f, 0.96f, mastery) * reveal }; Texture2D texture = DeathDomainBackdropTextureSystem.GetLayer(layer); DeathDomainBackdropTextureSystem.GetExpandedDomainSourceFrame(layer, out float sourceOffsetX, out float sourceOffsetY, out float viewWidth, out float viewHeight); DrawBackdropBand( batch, texture, destination, destinationWidth, destinationHeight, sourceOffsetX, sourceOffsetY, viewWidth, viewHeight, opacity); } DrawTransientBackdropBlackHoles( batch, destination + new Vector2(destinationWidth, destinationHeight) * 0.5f, new Vector2(destinationWidth, destinationHeight) * 0.5f, mastery, reveal, 0f, Main.worldID); } private static void DrawTransientBackdropBlackHoles( SpriteBatch batch, Vector2 center, Vector2 halfExtent, float mastery, float domainReveal, float clipRadius, int seedGroup) { if (domainReveal <= 0.01f || halfExtent.X <= 8f || halfExtent.Y <= 8f) return; bool clipped = clipRadius > 0f; int largeCount = clipped ? 1 : 2; int smallCount = clipped ? 3 : 6; float minimumExtent = Math.Min(halfExtent.X, halfExtent.Y); float largeRadius = clipped ? MathHelper.Clamp(clipRadius * 0.13f, 13f, 52f) : MathHelper.Clamp(minimumExtent * 0.115f, 58f, 138f); float smallRadius = clipped ? MathHelper.Clamp(clipRadius * 0.058f, 7f, 25f) : MathHelper.Clamp(minimumExtent * 0.043f, 20f, 52f); for (int slot = 0; slot < largeCount + smallCount; slot++) { bool large = slot < largeCount; int seed = seedGroup * 37 + slot * 193 + (large ? 701 : 1301); if (!TryGetBackdropHoleAnimation(seed, large, out float opacity, out float scale, out float collapse)) { continue; } Vector2 position; if (clipped) { float angle = Hash01(seed + 11) * MathHelper.TwoPi; float distance = clipRadius * MathHelper.Lerp( large ? 0.19f : 0.27f, large ? 0.42f : 0.62f, Hash01(seed + 23)); position = center + angle.ToRotationVector2() * distance; } else { position = center + new Vector2( MathHelper.Lerp(-0.76f, 0.76f, Hash01(seed + 11)) * halfExtent.X, MathHelper.Lerp(-0.68f, 0.68f, Hash01(seed + 23)) * halfExtent.Y); } float finalOpacity = opacity * domainReveal * MathHelper.Lerp(0.72f, 1f, mastery); float rotation = Hash01(seed + 41) * MathHelper.TwoPi + Main.GlobalTimeWrappedHourly * (large ? 0.055f : -0.16f); if (large) { DrawLargeBackdropBlackHole(batch, position, largeRadius * scale, rotation, finalOpacity, collapse, mastery, seed); } else { DrawSmallBackdropBlackHole(batch, position, smallRadius * scale, rotation, finalOpacity, collapse, seed); } } } private static bool TryGetBackdropHoleAnimation( int seed, bool large, out float opacity, out float scale, out float collapse) { float cycle = large ? 780f : 570f; float visibleDuration = large ? 470f : 330f; float introDuration = large ? 58f : 40f; float outroDuration = large ? 152f : 112f; float offset = Hash01(seed + 67) * cycle; float age = PositiveModulo((float)Main.GameUpdateCount + offset, cycle); if (age >= visibleDuration) { opacity = 0f; scale = 0f; collapse = 0f; return false; } float intro = MathHelper.SmoothStep(0f, 1f, MathHelper.Clamp(age / introDuration, 0f, 1f)); float outro = MathHelper.SmoothStep(0f, 1f, MathHelper.Clamp((visibleDuration - age) / outroDuration, 0f, 1f)); opacity = Math.Min(intro, outro); collapse = 1f - outro; float openingScale = MathHelper.Lerp(0.08f, 1f, 1f - (float)Math.Pow(1f - intro, 3f)); openingScale *= 1f + (float)Math.Sin(intro * MathHelper.Pi) * 0.12f; scale = openingScale * MathHelper.Lerp(0.18f, 1f, outro); return opacity > 0.005f && scale > 0.02f; } private static void DrawSmallBackdropBlackHole( SpriteBatch batch, Vector2 center, float radius, float rotation, float opacity, float collapse, int seed) { if (radius <= 1f || opacity <= 0.001f) return; DrawBackdropDomainBlackHole(batch, center, radius, rotation, opacity, collapse, 0.34f, seed, large: false); } private static void DrawLargeBackdropBlackHole( SpriteBatch batch, Vector2 center, float radius, float rotation, float opacity, float collapse, float mastery, int seed) { if (radius <= 2f || opacity <= 0.001f) return; DrawBackdropDomainBlackHole(batch, center, radius, rotation, opacity, collapse, mastery, seed, large: true); } private static void DrawBackdropDomainBlackHole( SpriteBatch batch, Vector2 center, float radius, float rotation, float opacity, float collapse, float mastery, int seed, bool large) { float time = Main.GlobalTimeWrappedHourly; float coreRadius = radius * (large ? 0.68f : 0.62f); float pulse = opacity * (0.88f + (float)Math.Sin(time * 2.7f + seed) * 0.08f); DeathDomainPrimitiveTextureSystem.DrawRadialGradient(batch, center, radius * (large ? 1.95f : 1.65f), new Color(42, 0, 16) * (opacity * 0.42f)); DrawBlackHoleSuctionStreams(batch, center, radius, 1f, rotation, opacity * (1f - collapse * 0.38f), seed, large); // Five inward-curving arms deliberately mirror the player's event // horizon instead of the old unrelated crossed-orbit atom symbol. for (int arm = 0; arm < 5; arm++) DrawAccretionSpiral(batch, center, coreRadius * 0.96f, radius * 1.06f, time + rotation * 0.72f, arm, mastery, pulse); DeathDomainPrimitiveTextureSystem.DrawSmoothDisc(batch, center, coreRadius, new Color(0, 0, 2) * (opacity * 0.98f)); DrawNoiseBoundary(batch, center, coreRadius + radius * 0.07f, time * 0.76f + rotation, seed * 0.013f, radius * 0.07f, large ? 3.2f : 2.1f, opacity * 0.34f, mastery, gaps: true); DrawNoiseBoundary(batch, center, coreRadius, -time * 0.94f + rotation, seed * 0.021f, radius * 0.045f, large ? 2.4f : 1.55f, opacity * 0.92f, mastery, gaps: false); } private static void DrawFilledEllipse( SpriteBatch batch, Vector2 center, float radiusX, float radiusY, Color color) { int bands = Math.Clamp((int)Math.Ceiling(radiusY), 6, 72); for (int band = 0; band < bands; band++) { float normalizedY = (band + 0.5f) / bands * 2f - 1f; float halfWidth = radiusX * (float)Math.Sqrt(Math.Max(0f, 1f - normalizedY * normalizedY)); float y = center.Y + normalizedY * radiusY; DrawSegment(batch, new Vector2(center.X - halfWidth, y), new Vector2(halfWidth * 2f, 0f), color, Math.Max(1f, radiusY * 2f / bands + 0.6f)); } } private static void DrawEllipseArc( SpriteBatch batch, Vector2 center, float radiusX, float radiusY, float rotation, float startAngle, float endAngle, int segments, Color color, float width) { Vector2 AxisPoint(float angle) { Vector2 local = new((float)Math.Cos(angle) * radiusX, (float)Math.Sin(angle) * radiusY); return center + local.RotatedBy(rotation); } Vector2 previous = AxisPoint(startAngle); for (int index = 1; index <= segments; index++) { float angle = MathHelper.Lerp(startAngle, endAngle, index / (float)segments); Vector2 current = AxisPoint(angle); DrawSegment(batch, previous, current - previous, color, width); previous = current; } } private static float Hash01(int seed) { double value = Math.Sin(seed * 12.9898 + 78.233) * 43758.5453; return (float)(value - Math.Floor(value)); } private static void DrawDomainRippleWaves(SpriteBatch batch, Vector2 center, float radius, float time, float mastery, float pulse) { for (int wave = 0; wave < 3; wave++) { float travel = PositiveModulo(time * (0.22f + mastery * 0.08f) + wave / 3f, 1f); float waveRadius = radius + MathHelper.Lerp(8f, 64f, travel); float opacity = (1f - travel) * pulse * (0.14f + mastery * 0.08f); const int segments = 128; float firstRadius = waveRadius + (float)Math.Sin(-time * 2.1f + wave) * 2.6f; Vector2 previous = center + Vector2.UnitX * firstRadius; for (int segment = 1; segment <= segments; segment++) { float angle = MathHelper.TwoPi * segment / segments; float ripple = (float)Math.Sin(angle * 9f - time * 2.1f + wave * 1.7f) * 2.6f + (float)Math.Sin(angle * 17f + time * 1.3f) * 0.9f; Vector2 current = center + angle.ToRotationVector2() * (waveRadius + ripple); DeathDomainPrimitiveTextureSystem.DrawSmoothStrip(batch, previous, current, Color.Lerp(new Color(122, 4, 34), new Color(255, 54, 92), mastery) * opacity, MathHelper.Lerp(1.1f, 2.4f, 1f - travel)); previous = current; } } } private static void DrawBlackHoleSuctionStreams(SpriteBatch batch, Vector2 center, float radius, float verticalScale, float rotation, float opacity, int seed, bool large) { int streams = large ? 10 : 6; int points = large ? 30 : 22; float time = Main.GlobalTimeWrappedHourly; for (int stream = 0; stream < streams; stream++) { float streamOffset = stream * MathHelper.TwoPi / streams + seed * 0.013f; float movingHead = 1f - PositiveModulo(time * (large ? 0.42f : 0.58f) + stream / (float)streams, 1f); Vector2 previous = Vector2.Zero; for (int point = 0; point < points; point++) { float inward = point / (points - 1f); float curved = inward * inward * (3f - 2f * inward); float localAngle = streamOffset - time * (large ? 0.18f : 0.27f) + inward * (large ? 1.72f : 1.38f); float localRadius = MathHelper.Lerp(radius * (large ? 2.75f : 2.05f), radius * 0.56f, curved); localRadius += (float)Math.Sin(localAngle * 5f + seed * 0.07f) * radius * 0.025f * (1f - inward); Vector2 local = new((float)Math.Cos(localAngle) * localRadius, (float)Math.Sin(localAngle) * localRadius * verticalScale); Vector2 current = center + local.RotatedBy(rotation); if (point > 0) { float body = (float)Math.Sin(inward * MathHelper.Pi); float glow = 1f - MathHelper.SmoothStep(0f, 1f, MathHelper.Clamp(Math.Abs(inward - movingHead) / 0.20f, 0f, 1f)); // The near half of the tilted disc is brighter and thicker; // the far half remains visible behind the black core, giving // the accretion flow genuine front/back depth. float nearSide = 0.55f + 0.45f * Math.Max(0f, (float)Math.Sin(localAngle)); Color color = Color.Lerp(new Color(58, 0, 12), new Color(255, 42, 78), inward * 0.82f + glow * 0.18f); float streamOpacity = opacity * body * nearSide * (0.18f + glow * 0.62f); float width = MathHelper.Lerp(0.65f, large ? 3.6f : 2.2f, inward) * (0.72f + glow * 0.38f); DeathDomainPrimitiveTextureSystem.DrawSmoothStrip(batch, previous, current, color * streamOpacity, width); } previous = current; } } } private static void DrawFilledNoiseDisc( SpriteBatch batch, Vector2 center, float baseRadius, float time, float seed, float noiseAmplitude, Color color) { const int boundarySegments = 192; float preferredBandHeight = MathHelper.Clamp(baseRadius / 240f, 3f, 9f); Span boundary = stackalloc Vector2[boundarySegments]; float extent = 0f; for (int index = 0; index < boundarySegments; index++) { float angle = MathHelper.TwoPi * index / boundarySegments; float radius = Math.Max(1f, baseRadius + BoundaryNoise(angle, time, seed) * noiseAmplitude); boundary[index] = angle.ToRotationVector2() * radius; extent = Math.Max(extent, Math.Abs(boundary[index].Y)); } int bands = Math.Max(1, (int)Math.Ceiling(extent * 2f / preferredBandHeight)); float bandHeight = extent * 2f / bands; for (int index = 0; index < bands; index++) { float scanY = -extent + (index + 0.5f) * bandHeight; float left = float.MaxValue; float right = float.MinValue; for (int edge = 0; edge < boundarySegments; edge++) { Vector2 start = boundary[edge]; Vector2 end = boundary[(edge + 1) % boundarySegments]; if ((start.Y <= scanY && end.Y > scanY) || (end.Y <= scanY && start.Y > scanY)) { float amount = (scanY - start.Y) / (end.Y - start.Y); float intersectionX = MathHelper.Lerp(start.X, end.X, amount); left = Math.Min(left, intersectionX); right = Math.Max(right, intersectionX); } } if (left >= right) continue; Vector2 fillStart = new(center.X + left, center.Y + scanY); Vector2 fillEnd = new(center.X + right, center.Y + scanY); DeathDomainPrimitiveTextureSystem.DrawSmoothLine(batch, fillStart, fillEnd, color, bandHeight + 0.8f); } } private static void DrawNoiseBoundary( SpriteBatch batch, Vector2 center, float baseRadius, float time, float seed, float noiseAmplitude, float width, float opacity, float mastery, bool gaps) { const int segments = 256; float previousAngle = 0f; float previousRadius = baseRadius + BoundaryNoise(previousAngle, time, seed) * noiseAmplitude; Vector2 previous = center + Vector2.UnitX * previousRadius; Color darkRed = Color.Lerp(new Color(58, 0, 12), new Color(118, 0, 25), mastery); Color brightRed = Color.Lerp(new Color(225, 12, 42), new Color(255, 72, 105), mastery); for (int index = 1; index <= segments; index++) { float angle = MathHelper.TwoPi * index / segments; float noisyRadius = baseRadius + BoundaryNoise(angle, time, seed) * noiseAmplitude; Vector2 current = center + angle.ToRotationVector2() * noisyRadius; Vector2 segment = current - previous; float middleAngle = (previousAngle + angle) * 0.5f; float flow = 0.5f + 0.5f * (float)Math.Sin(middleAngle * 3f - time * 1.9f + seed); float arcMask = 0.58f + 0.42f * (float)Math.Sin(middleAngle * 2f + time * 0.47f + seed * 1.31f); arcMask *= 0.72f + 0.28f * (float)Math.Sin(middleAngle * 7f - time * 0.83f + seed * 0.73f); float visibility = gaps ? MathHelper.SmoothStep(0.08f, 0.62f, arcMask) : 1f; if (visibility > 0.025f) { Color color = Color.Lerp(darkRed, brightRed, flow); DrawSegment(batch, previous, segment, color * (opacity * visibility), width * (0.72f + flow * 0.42f)); } previousAngle = angle; previous = current; } } private static float BoundaryNoise(float angle, float time, float seed) { float low = (float)Math.Sin(angle * 3f + time * 0.72f + seed) * 0.48f; float middle = (float)Math.Sin(angle * 7f - time * 1.11f + seed * 1.73f) * 0.29f; float high = (float)Math.Sin(angle * 13f + time * 1.67f + seed * 0.37f) * 0.16f; float grain = (float)Math.Sin(angle * 29f - time * 2.23f + seed * 2.17f) * 0.07f; return low + middle + high + grain; } private static void DrawInwardSuctionWaves( SpriteBatch batch, Vector2 center, float edgeRadius, float coreRadius, float time, float mastery, float pulse) { // Preserve the original long black-hole accretion lines. Only the animated // highlight direction is reversed so they now read as being swallowed by // the event horizon instead of being emitted from it. int streamCount = 7 + (int)Math.Round(mastery * 5f); float outerRadius = edgeRadius + MathHelper.Lerp(72f, 126f, mastery); for (int stream = 0; stream < streamCount; stream++) { float seed = stream * 4.731f + 2.4f; float movingHead = 1f - PositiveModulo(time * MathHelper.Lerp(0.48f, 0.78f, mastery) + stream / (float)streamCount, 1f); float baseAngle = stream * MathHelper.TwoPi / streamCount - time * (0.13f + mastery * 0.08f) + BoundaryNoise(stream, time * 0.18f, seed) * 0.06f; const int points = 46; Vector2 previous = Vector2.Zero; for (int point = 0; point < points; point++) { float inward = point / (points - 1f); float curved = inward * inward * (3f - 2f * inward); float angle = baseAngle + inward * MathHelper.Lerp(1.08f, 1.54f, mastery); float radius = MathHelper.Lerp(outerRadius, edgeRadius + 2f, curved) + BoundaryNoise(angle, -time * 0.82f, seed) * MathHelper.Lerp(5.2f, 1.2f, inward); Vector2 current = center + angle.ToRotationVector2() * radius; if (point > 0) { float bodyFade = (float)Math.Sin(inward * MathHelper.Pi); float distanceFromHead = Math.Abs(inward - movingHead); float movingGlow = 1f - MathHelper.SmoothStep(0.02f, 0.25f, distanceFromHead); float opacity = bodyFade * pulse * (0.16f + movingGlow * 0.64f); Color color = Color.Lerp( new Color(18, 0, 8), Color.Lerp(new Color(174, 3, 40), new Color(255, 34, 76), mastery), inward * (0.45f + movingGlow * 0.55f)); float width = MathHelper.Lerp(0.55f, 3.4f + mastery * 1.7f, inward) * (0.68f + movingGlow * 0.48f); DrawSegment(batch, previous, current - previous, color * opacity, width); } previous = current; } } } private static void DrawOutwardPressureWaves( SpriteBatch batch, Vector2 center, float coreRadius, float time, float mastery, float pulse) { // This is the same continuous accretion-line construction used outside, // with radius progression, spiral turn and highlight travel all inverted. int streamCount = 7 + (int)Math.Round(mastery * 5f); float innerRadius = Math.Max(5f, coreRadius * 0.06f); float outerRadius = coreRadius * 0.90f; for (int stream = 0; stream < streamCount; stream++) { float seed = 42.7f + stream * 3.117f; float movingHead = PositiveModulo(time * MathHelper.Lerp(0.48f, 0.78f, mastery) + stream / (float)streamCount, 1f); float baseAngle = stream * MathHelper.TwoPi / streamCount + time * (0.13f + mastery * 0.08f) - BoundaryNoise(stream, time * 0.18f, seed) * 0.06f; const int points = 40; Vector2 previous = Vector2.Zero; for (int point = 0; point < points; point++) { float outward = point / (points - 1f); float curved = outward * outward * (3f - 2f * outward); float angle = baseAngle - outward * MathHelper.Lerp(1.08f, 1.54f, mastery); float radius = MathHelper.Lerp(innerRadius, outerRadius, curved) + BoundaryNoise(angle, time * 0.82f, seed) * MathHelper.Lerp(1.2f, 4.6f, outward); Vector2 current = center + angle.ToRotationVector2() * radius; if (point > 0) { float bodyFade = (float)Math.Sin(outward * MathHelper.Pi); float distanceFromHead = Math.Abs(outward - movingHead); float movingGlow = 1f - MathHelper.SmoothStep(0.02f, 0.25f, distanceFromHead); float opacity = bodyFade * pulse * (0.13f + movingGlow * 0.58f); Color color = Color.Lerp( new Color(255, 116, 136), Color.Lerp(new Color(112, 2, 28), new Color(220, 18, 58), mastery), outward * 0.78f); float width = MathHelper.Lerp(3.5f + mastery * 1.4f, 0.65f, outward) * (0.66f + movingGlow * 0.46f); DrawSegment(batch, previous, current - previous, color * opacity, width); } previous = current; } } } private static void DrawAccretionSpiral(SpriteBatch batch, Vector2 center, float coreRadius, float outerRadius, float time, int arm, float mastery, float pulse) { const int points = 34; float armOffset = arm * MathHelper.TwoPi / 5f; Vector2 previous = Vector2.Zero; for (int index = 0; index < points; index++) { float progress = index / (points - 1f); float angle = armOffset - time * (0.72f + arm * 0.035f) + progress * MathHelper.Pi * 1.12f; float radius = MathHelper.Lerp(coreRadius * 0.88f, outerRadius, progress); radius += BoundaryNoise(angle, time, arm * 2.7f) * 2.4f; Vector2 current = center + angle.ToRotationVector2() * radius; if (index > 0) { Vector2 segment = current - previous; Color color = Color.Lerp(new Color(255, 70, 92), new Color(82, 0, 22), progress); float fade = (float)Math.Sin(progress * MathHelper.Pi); DrawSegment(batch, previous, segment, color * (fade * pulse * 0.54f), MathHelper.Lerp(2.6f + mastery, 0.7f, progress)); } previous = current; } } private static void DrawCircle(SpriteBatch batch, Vector2 center, float radius, int segments, Color color, float width, float rotation) { segments = Math.Max(segments, Math.Min(192, (int)Math.Ceiling(MathHelper.TwoPi * radius / 18f))); Vector2 previous = center + rotation.ToRotationVector2() * radius; for (int index = 1; index <= segments; index++) { float angle = rotation + MathHelper.TwoPi * index / segments; Vector2 current = center + angle.ToRotationVector2() * radius; DeathDomainPrimitiveTextureSystem.DrawSmoothStrip(batch, previous, current, color, width); previous = current; } } private static void DrawSegment(SpriteBatch batch, Vector2 start, Vector2 segment, Color color, float width) { DeathDomainPrimitiveTextureSystem.DrawSmoothStrip(batch, start, start + segment, color, width); } private static float PositiveModulo(float value, float modulus) { float result = value % modulus; return result < 0f ? result + modulus : result; } private static float GetMasteryProgress(int mastery) { // The character-owned Death Domain tree is packed into the legacy 5..75 // visual range so existing shaders keep the same progression curve. return MathHelper.Clamp((mastery - 5f) / 70f, 0f, 1f); } private void ClearAnimationState() { Array.Clear(domainAnimation, 0, domainAnimation.Length); Array.Clear(descentAnimation, 0, descentAnimation.Length); Array.Clear(cachedRadius, 0, cachedRadius.Length); Array.Clear(cachedMastery, 0, cachedMastery.Length); Array.Clear(cachedFullScreen, 0, cachedFullScreen.Length); } }