using SoulHarvest.Items; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using System; using Terraria; using Terraria.ModLoader; namespace SoulHarvest.Common; /// /// Procedurally builds the three seamless plates used by the Death Domain's /// clipped parallax window. Textures are created lazily from the draw thread so /// no FNA graphics resource is ever allocated by a content-loader worker. /// [Autoload(Side = ModSide.Client)] internal sealed class DeathDomainBackdropTextureSystem : ModSystem { internal const int LayerWidth = 4096; internal const int LayerHeight = 1024; internal const float ReferenceDomainDiameter = (160f + DeathNecklace.RadiusGrowthPerLevel * (DeathNecklace.MaxCoreLevel - 1)) * 2f; private static readonly Texture2D?[] layers = new Texture2D?[3]; private static readonly float[] expandedSourceX = new float[3]; private static readonly float[] expandedSourceY = new float[3]; private static readonly float[] expandedSourceWidth = new float[3]; private static readonly float[] expandedSourceHeight = new float[3]; private static ulong expandedFrameTick = ulong.MaxValue; private static Vector2 expandedDestination; private static float expandedDestinationWidth = 1f; private static float expandedDestinationHeight = 1f; internal static Texture2D GetLayer(int index) { index = Math.Clamp(index, 0, layers.Length - 1); return layers[index] ??= CreateLayer(index); } internal static void Prewarm() { if (Main.dedServ) return; for (int layer = 0; layer < layers.Length; layer++) _ = GetLayer(layer); } /// /// Returns the single world-space frame used by a fully manifested Death /// Domain. Every smaller mask (necklace aperture, weapon wound and wing /// trail) samples this frame and merely clips it, preventing the scenery /// inside a cut from being enlarged to fit that cut. /// internal static void GetExpandedDomainSourceFrame(int layer, out float sourceOffsetX, out float sourceOffsetY, out float viewWidth, out float viewHeight) { EnsureExpandedDomainFrame(); layer = Math.Clamp(layer, 0, layers.Length - 1); sourceOffsetX = expandedSourceX[layer]; sourceOffsetY = expandedSourceY[layer]; viewWidth = expandedSourceWidth[layer]; viewHeight = expandedSourceHeight[layer]; } /// /// Returns the rectangle occupied by the fully manifested domain in the /// active world SpriteBatch's pre-transform coordinate space. This is also /// the normalization frame for every world-space domain mask, so game zoom /// cannot make weapon or wing cuts drift away from the necklace backdrop. /// internal static void GetExpandedDomainDrawBounds( out Vector2 destination, out float destinationWidth, out float destinationHeight) { EnsureExpandedDomainFrame(); destination = expandedDestination; destinationWidth = expandedDestinationWidth; destinationHeight = expandedDestinationHeight; } private static void EnsureExpandedDomainFrame() { // A crescent or merged trail can contain thousands of vertices. Cache // the inverse view transform and all three source plates once per game // update instead of repeating a matrix inversion for every vertex. if (expandedFrameTick == Main.GameUpdateCount) return; expandedFrameTick = Main.GameUpdateCount; Matrix inverse = Matrix.Invert(Main.GameViewMatrix.TransformationMatrix); Vector2 topLeft = Vector2.Transform(Vector2.Zero, inverse); Vector2 topRight = Vector2.Transform( new Vector2(Main.screenWidth, 0f), inverse); Vector2 bottomLeft = Vector2.Transform( new Vector2(0f, Main.screenHeight), inverse); Vector2 bottomRight = Vector2.Transform( new Vector2(Main.screenWidth, Main.screenHeight), inverse); float left = Math.Min(Math.Min(topLeft.X, topRight.X), Math.Min(bottomLeft.X, bottomRight.X)); float right = Math.Max(Math.Max(topLeft.X, topRight.X), Math.Max(bottomLeft.X, bottomRight.X)); float top = Math.Min(Math.Min(topLeft.Y, topRight.Y), Math.Min(bottomLeft.Y, bottomRight.Y)); float bottom = Math.Max(Math.Max(topLeft.Y, topRight.Y), Math.Max(bottomLeft.Y, bottomRight.Y)); expandedDestination = new Vector2(left, top); expandedDestinationWidth = Math.Max(1f, right - left); expandedDestinationHeight = Math.Max(1f, bottom - top); Vector2 sourceAperture = new( Math.Max(1, Main.screenWidth), Math.Max(1, Main.screenHeight)); Vector2 sourceAnchor = Main.screenPosition + sourceAperture * 0.5f; for (int layer = 0; layer < layers.Length; layer++) { GetSourceFrame(layer, sourceAnchor, sourceAperture, out expandedSourceX[layer], out expandedSourceY[layer], out expandedSourceWidth[layer], out expandedSourceHeight[layer]); } } /// /// Maps a world point into the exact parallax plate framing used by the Death /// Necklace. Weapon crescents and persistent wounds use this instead of raw /// texture-size UVs, so all three layers have the same scale, anchor and /// parallax as the domain rather than forming a differently scaled collage. /// internal static Vector2 GetWorldUv(int layer, Vector2 world, Vector2 anchorWorld, Vector2 apertureSize) { apertureSize.X = Math.Max(1f, apertureSize.X); apertureSize.Y = Math.Max(1f, apertureSize.Y); GetSourceFrame(layer, anchorWorld, apertureSize, out float sourceOffsetX, out float sourceOffsetY, out float viewWidth, out float viewHeight); Vector2 normalized = (world - anchorWorld) / apertureSize + new Vector2(0.5f); return new Vector2( (sourceOffsetX + normalized.X * viewWidth) / LayerWidth, (sourceOffsetY + normalized.Y * viewHeight) / LayerHeight); } internal static Vector2 GetWorldUv(int layer, Vector2 world, Vector2 anchorWorld) => GetWorldUv(layer, world, anchorWorld, new Vector2(ReferenceDomainDiameter)); /// /// Uses the fully manifested Death Necklace framing at all times. The owner /// and fallback anchor remain part of the call contract because masks are /// owner-scoped, but neither may rescale or recenter the shared backdrop. /// Every mask therefore reveals the same parallax pixel at the same visible /// world position before, during and after manifestation. /// internal static Vector2 GetMatchingDomainUv(int layer, Vector2 world, int owner, Vector2 fallbackAnchor) { _ = owner; _ = fallbackAnchor; GetExpandedDomainDrawBounds(out Vector2 destination, out float destinationWidth, out float destinationHeight); GetExpandedDomainSourceFrame(layer, out float sourceOffsetX, out float sourceOffsetY, out float viewWidth, out float viewHeight); Vector2 worldTopLeft = Main.screenPosition + destination; Vector2 normalized = new( (world.X - worldTopLeft.X) / destinationWidth, (world.Y - worldTopLeft.Y) / destinationHeight); return new Vector2( (sourceOffsetX + normalized.X * viewWidth) / LayerWidth, (sourceOffsetY + normalized.Y * viewHeight) / LayerHeight); } internal static float GetLayerOpacity(int layer) => layer switch { 0 => 1f, 1 => 0.9f, _ => 0.96f }; /// /// Draws the actual three in-game Death Domain plates as a compact UI icon. /// The deeper plates move less than the near mist, so cursor motion and the /// ambient drift preserve the same parallax language as the world aperture. /// internal static void DrawUiIcon( SpriteBatch batch, Rectangle destination, Vector2 focusPoint, float opacity = 1f) { if (Main.dedServ || destination.Width <= 0 || destination.Height <= 0) return; Vector2 center = destination.Center.ToVector2(); Vector2 halfSize = new( Math.Max(1f, destination.Width * 0.5f), Math.Max(1f, destination.Height * 0.5f)); Vector2 cursor = (focusPoint - center) / halfSize; cursor.X = MathHelper.Clamp(cursor.X, -1f, 1f); cursor.Y = MathHelper.Clamp(cursor.Y, -1f, 1f); Vector2 worldCenter = Main.LocalPlayer.active ? Main.LocalPlayer.Center : Main.screenPosition + new Vector2(Main.screenWidth, Main.screenHeight) * 0.5f; Vector2 apertureSize = new(ReferenceDomainDiameter * 0.72f); float time = Main.GlobalTimeWrappedHourly; for (int layer = 0; layer < 3; layer++) { Texture2D texture = GetLayer(layer); GetSourceFrame(layer, worldCenter, apertureSize, out float sourceX, out float sourceY, out float sourceWidth, out float sourceHeight); float depth = layer switch { 0 => 0.34f, 1 => 0.68f, _ => 1f }; sourceX += cursor.X * 54f * depth + (float)Math.Sin(time * (0.22f + layer * 0.07f) + layer) * 22f * depth; sourceY += cursor.Y * 32f * depth + (float)Math.Cos(time * (0.18f + layer * 0.05f) + layer * 1.7f) * 13f * depth; sourceX = MathHelper.Clamp(sourceX, 0f, Math.Max(0f, texture.Width - sourceWidth)); sourceY = MathHelper.Clamp(sourceY, 0f, Math.Max(0f, texture.Height - sourceHeight)); int sourceWidthPixels = Math.Clamp( (int)MathF.Round(sourceWidth), 1, texture.Width); int sourceHeightPixels = Math.Clamp( (int)MathF.Round(sourceHeight), 1, texture.Height); Rectangle source = new( Math.Clamp((int)MathF.Round(sourceX), 0, texture.Width - sourceWidthPixels), Math.Clamp((int)MathF.Round(sourceY), 0, texture.Height - sourceHeightPixels), sourceWidthPixels, sourceHeightPixels); batch.Draw(texture, destination, source, Color.White * (MathHelper.Clamp(opacity, 0f, 1f) * GetLayerOpacity(layer))); } } /// /// Draws the same world-anchored plates used by the descended Death Domain /// into a physical-screen rectangle. The ultimate uses this behind displaced /// mirror shards so every opening reveals the real domain at its normal scale. /// internal static void DrawScreenAlignedBackdrop(SpriteBatch batch, Rectangle destination, float opacity) { if (Main.dedServ || destination.Width <= 0 || destination.Height <= 0 || opacity <= 0.001f) { return; } for (int layer = 0; layer < 3; layer++) { Texture2D texture = GetLayer(layer); GetExpandedDomainSourceFrame(layer, out float sourceX, out float sourceY, out float sourceWidth, out float sourceHeight); int width = Math.Clamp((int)MathF.Round(sourceWidth), 1, texture.Width); int height = Math.Clamp((int)MathF.Round(sourceHeight), 1, texture.Height); Rectangle source = new( Math.Clamp((int)MathF.Round(sourceX), 0, texture.Width - width), Math.Clamp((int)MathF.Round(sourceY), 0, texture.Height - height), width, height); batch.Draw(texture, destination, source, Color.White * (MathHelper.Clamp(opacity, 0f, 1f) * GetLayerOpacity(layer))); } } /// Returns the necklace's source rectangle for a world aperture. internal static void GetSourceFrame(int layer, Vector2 worldCenter, Vector2 apertureSize, out float sourceOffsetX, out float sourceOffsetY, out float viewWidth, out float viewHeight) { layer = Math.Clamp(layer, 0, layers.Length - 1); float referenceViewWidth = 1024f * (layer switch { 0 => 0.68f, 1 => 0.9f, _ => 0.84f }); float referenceViewHeight = 512f * (layer switch { 0 => 0.78f, 1 => 0.9f, _ => 0.86f }); float horizontalScale = Math.Max(0.001f, apertureSize.X / ReferenceDomainDiameter); float verticalScale = Math.Max(0.001f, apertureSize.Y / ReferenceDomainDiameter); viewWidth = Math.Min(LayerWidth, referenceViewWidth * horizontalScale); viewHeight = Math.Min(LayerHeight, referenceViewHeight * verticalScale); float worldWidth = Math.Max(1f, Main.maxTilesX * 16f); float worldHeight = Math.Max(1f, Main.maxTilesY * 16f); float worldX = MathHelper.Clamp(worldCenter.X / worldWidth, 0f, 1f); float worldY = MathHelper.Clamp(worldCenter.Y / worldHeight, 0f, 1f); float travelFactor = layer switch { 0 => 0.34f, 1 => 0.66f, _ => 1f }; float horizontalTravel = Math.Max(0f, LayerWidth - viewWidth) * travelFactor; sourceOffsetX = (LayerWidth - viewWidth - horizontalTravel) * 0.5f + horizontalTravel * worldX; sourceOffsetY = worldY * Math.Max(0f, LayerHeight - viewHeight); } /// /// Samples the exact three procedural plates used by the necklace domain and /// composites them in premultiplied-alpha order. Death weapon masks use this /// method so their interior is the domain itself, not a similarly colored /// substitute texture. /// internal static Color SampleCompositePixel(int x, int y, float u, float v) { Color result = BuildFarPixel(x, y, u, v); result = CompositeOver(result, BuildMiddlePixel(u, v)); result = CompositeOver(result, BuildNearPixel(u, v)); return result; } public override void Unload() { expandedFrameTick = ulong.MaxValue; Texture2D?[] oldLayers = (Texture2D?[])layers.Clone(); Array.Clear(layers); if (Main.dedServ) return; Main.QueueMainThreadAction(() => { foreach (Texture2D? texture in oldLayers) texture?.Dispose(); }); } private static Texture2D CreateLayer(int layer) { Texture2D texture = new(Main.instance.GraphicsDevice, LayerWidth, LayerHeight, false, SurfaceFormat.Color); Color[] pixels = new Color[LayerWidth * LayerHeight]; for (int y = 0; y < LayerHeight; y++) { float v = (y + 0.5f) / LayerHeight; for (int x = 0; x < LayerWidth; x++) { // Keep roughly the same feature scale as the original 1024-wide // plate while supplying enough unique scenery for a 4K full-screen // view at exactly the same scale as the player's circular window. float u = (x + 0.5f) / 1024f; pixels[y * LayerWidth + x] = layer switch { 0 => BuildFarPixel(x, y, u, v), 1 => BuildMiddlePixel(u, v), _ => BuildNearPixel(u, v) }; } } texture.SetData(pixels); return texture; } private static Color BuildFarPixel(int x, int y, float u, float v) { Vector3 top = new(0.018f, 0.006f, 0.055f); Vector3 bottom = new(0.105f, 0.004f, 0.072f); Vector3 color = Vector3.Lerp(top, bottom, Smooth01(v)); float crimsonCloud = Wave(u, v, 2f, 1f, 0.13f) + Wave(u, v, 5f, -2f, 1.7f) * 0.45f + Wave(u, v, 9f, 3f, 4.2f) * 0.2f + Wave(u, v, 0.37f, 0.45f, 2.3f) * 0.24f; crimsonCloud = Smooth01((crimsonCloud + 1.1f) / 2.45f); float violetCloud = Smooth01((Wave(u, v, 3f, -2f, 3.4f) + 0.78f) / 1.78f); color += new Vector3(0.16f, 0.006f, 0.055f) * crimsonCloud * (0.32f + v * 0.34f); color += new Vector3(0.035f, 0.018f, 0.13f) * violetCloud * 0.42f; float hash = Hash01(x, y, 9137); if (hash > 0.9962f) { float star = MathHelper.Clamp((hash - 0.9962f) / 0.0038f, 0f, 1f); Vector3 starColor = (x + y) % 5 == 0 ? new Vector3(1f, 0.22f, 0.34f) : new Vector3(0.62f, 0.38f, 0.92f); color = Vector3.Lerp(color, starColor, 0.42f + star * 0.58f); } // Background singularities are rendered as independent transient actors // by DeathDomainVisualSystem. Keeping them out of this baked plate allows // each one to form, live briefly, collapse, and leave a true empty interval. return Opaque(color); } private static Color BuildMiddlePixel(float u, float v) { Vector3 color = Vector3.Zero; float alpha = 0f; // A distant eclipsed blood moon with a luminous, broken corona. Vector2 moonOffset = new((u - 0.28f) * 2f, (v - 0.28f) * 4f); float moonDistance = moonOffset.Length(); float corona = 1f - SmoothRange(0.19f, 0.245f, moonDistance); float hollow = SmoothRange(0.115f, 0.17f, moonDistance); float moonRing = MathHelper.Clamp(corona * hollow, 0f, 1f); if (moonRing > 0f) { float fracture = 0.72f + 0.28f * (float)Math.Sin(Math.Atan2(moonOffset.Y, moonOffset.X) * 11f); alpha = moonRing * fracture * 0.88f; color = new Vector3(0.72f, 0.025f, 0.15f); } float cloud = Smooth01((Wave(u, v, 4f, 1f, 0.7f) + Wave(u, v, 7f, -2f, 2.9f) * 0.55f + 0.55f) / 2.1f); float cloudAlpha = cloud * (1f - Math.Abs(v - 0.48f) * 1.55f) * 0.2f; BlendPremultiplied(ref color, ref alpha, new Vector3(0.42f, 0.02f, 0.13f), Math.Max(0f, cloudAlpha)); // Two ranges of dead mountains create the mid-distance depth plane. float rearHorizon = 0.59f + (float)Math.Sin(u * MathHelper.TwoPi * 3f + 0.4f) * 0.06f + (float)Math.Sin(u * MathHelper.TwoPi * 7f) * 0.025f + (float)Math.Sin(u * MathHelper.TwoPi * 0.31f + 1.4f) * 0.024f; if (v > rearHorizon) { float depth = Smooth01((v - rearHorizon) / 0.18f); BlendPremultiplied(ref color, ref alpha, new Vector3(0.085f, 0.012f, 0.12f), 0.62f + depth * 0.14f); } float frontHorizon = 0.71f + (float)Math.Sin(u * MathHelper.TwoPi * 5f + 2.1f) * 0.055f + (float)Math.Sin(u * MathHelper.TwoPi * 11f) * 0.018f; if (v > frontHorizon) BlendPremultiplied(ref color, ref alpha, new Vector3(0.035f, 0.004f, 0.055f), 0.82f); return Premultiplied(color, alpha); } private static Color BuildNearPixel(float u, float v) { float broadMist = Wave(u, v, 2f, 1f, 0.35f) + Wave(u, v, 4f, -2f, 2.2f) * 0.52f + Wave(u, v, 7f, 3f, 4.6f) * 0.24f + Wave(u, v, 0.43f, -0.38f, 5.1f) * 0.26f; float curledMist = Wave(u, v, 3f, -3f, 1.1f) + Wave(u, v, 9f, 4f, 3.7f) * 0.34f; float body = Smooth01((broadMist + 0.72f) / 2.2f); float curls = Smooth01((curledMist + 0.82f) / 1.85f); float alpha = MathHelper.Clamp(body * 0.34f + body * curls * 0.32f, 0f, 0.62f); if (alpha <= 0.002f) return Color.Transparent; Vector3 color = Vector3.Lerp( new Vector3(0.18f, 0.002f, 0.035f), new Vector3(0.72f, 0.018f, 0.12f), curls * 0.72f); return Premultiplied(color, alpha); } private static void BlendPremultiplied(ref Vector3 color, ref float alpha, Vector3 sourceColor, float sourceAlpha) { sourceAlpha = MathHelper.Clamp(sourceAlpha, 0f, 1f); float combinedAlpha = sourceAlpha + alpha * (1f - sourceAlpha); if (combinedAlpha <= 0.0001f) return; color = (sourceColor * sourceAlpha + color * alpha * (1f - sourceAlpha)) / combinedAlpha; alpha = combinedAlpha; } private static Color Opaque(Vector3 color) => new( MathHelper.Clamp(color.X, 0f, 1f), MathHelper.Clamp(color.Y, 0f, 1f), MathHelper.Clamp(color.Z, 0f, 1f), 1f); private static Color Premultiplied(Vector3 color, float alpha) { alpha = MathHelper.Clamp(alpha, 0f, 1f); color *= alpha; return new Color( MathHelper.Clamp(color.X, 0f, 1f), MathHelper.Clamp(color.Y, 0f, 1f), MathHelper.Clamp(color.Z, 0f, 1f), alpha); } private static Color CompositeOver(Color destination, Color source) { Vector4 under = destination.ToVector4(); Vector4 over = source.ToVector4(); float remaining = 1f - over.W; return new Color( MathHelper.Clamp(over.X + under.X * remaining, 0f, 1f), MathHelper.Clamp(over.Y + under.Y * remaining, 0f, 1f), MathHelper.Clamp(over.Z + under.Z * remaining, 0f, 1f), MathHelper.Clamp(over.W + under.W * remaining, 0f, 1f)); } private static float Wave(float u, float v, float horizontalCycles, float verticalCycles, float phase) => (float)Math.Sin(MathHelper.TwoPi * (u * horizontalCycles + v * verticalCycles) + phase); private static float Hash01(int x, int y, int seed) { uint value = (uint)(x * 374761393 + y * 668265263 + seed * 1442695041); value = (value ^ (value >> 13)) * 1274126177u; value ^= value >> 16; return (value & 0x00FFFFFFu) / 16777215f; } private static float SmoothRange(float start, float end, float value) => Smooth01((value - start) / Math.Max(0.0001f, end - start)); private static float Smooth01(float value) { value = MathHelper.Clamp(value, 0f, 1f); return value * value * (3f - 2f * value); } private static float PositiveModulo(float value, float modulus) { float result = value % modulus; return result < 0f ? result + modulus : result; } }