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;
}
}