using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using System; using System.Collections.Generic; using Terraria; using Terraria.ModLoader; namespace SoulHarvest.Common; /// /// Draws the Death scythe's domain-filled face as geometry rather than as one /// rotating sprite. The crescent mask follows the weapon, while its texture /// coordinates stay anchored to world space; rapid swings therefore reveal a /// stable window into the Death Necklace domain instead of rotating the scene. /// [Autoload(Side = ModSide.Client)] internal sealed class DeathDomainCrescentVisualSystem : ModSystem { private const int AngularSegments = 128; private const int DepthBands = 18; private const int ResidualLifetimeFrames = 30; private const float HalfSweep = ReaperCombatRegistry.DeathPrimaryHalfSweep; private const float OuterRadiusRatio = 0.91f; // The outside blade edge stays on the weapon tip. Additional size grows only // toward the wielder, as requested, instead of increasing attack reach. private const float MaximumThicknessRatio = 0.585f; private static readonly Dictionary drawStates = []; private static long nextVisualInstanceId; private static BasicEffect? effect; private static VertexPositionColorTexture[][] layerVertices = [ new VertexPositionColorTexture[(AngularSegments + 1) * (DepthBands + 1)], new VertexPositionColorTexture[(AngularSegments + 1) * (DepthBands + 1)], new VertexPositionColorTexture[(AngularSegments + 1) * (DepthBands + 1)] ]; private static short[] indices = CreateIndices(); private static VertexPositionColorTexture[] rimVertices = new VertexPositionColorTexture[AngularSegments * 6]; private readonly record struct CrescentDrawState( int Owner, Vector2 Center, float Rotation, float Radius, float Opacity, int SwingDirection, float LifeProgress, bool PreserveInterior, ulong UpdateTick); /// /// Allocates a client-local identity for one physical swing. Projectile /// identities and slots can be reused before a residual crescent expires at /// maximum attack speed, so neither is safe as the draw-state key. /// internal static long AllocateVisualInstanceId() { nextVisualInstanceId++; if (nextVisualInstanceId <= 0) nextVisualInstanceId = 1; return nextVisualInstanceId; } internal static void Record(long visualInstanceId, int owner, Vector2 center, float rotation, float radius, float opacity, int swingDirection, float lifeProgress, bool preserveInterior = false) { if (Main.dedServ || visualInstanceId <= 0 || radius <= 1f || opacity <= 0.001f) return; if (drawStates.TryGetValue(visualInstanceId, out CrescentDrawState existing)) { // A slash is a cut made in world space, not an aura attached to the // player. Keep its first center, orientation and size immutable; // subsequent frames only advance the reveal frontier and refresh its // residual clock. Already drawn material therefore stays exactly // where the blade passed even if the player moves during the swing. drawStates[visualInstanceId] = existing with { Opacity = opacity, LifeProgress = Math.Max(existing.LifeProgress, MathHelper.Clamp(lifeProgress, 0f, 1f)), PreserveInterior = existing.PreserveInterior || preserveInterior, UpdateTick = Main.GameUpdateCount }; return; } drawStates[visualInstanceId] = new CrescentDrawState(owner, center, rotation, radius, opacity, swingDirection < 0 ? -1 : 1, MathHelper.Clamp(lifeProgress, 0f, 1f), preserveInterior, Main.GameUpdateCount); } public override void PostUpdateEverything() { if (drawStates.Count == 0) return; List? stale = null; foreach ((long key, CrescentDrawState state) in drawStates) { if (Main.GameUpdateCount <= state.UpdateTick + ResidualLifetimeFrames) continue; stale ??= []; stale.Add(key); } if (stale is null) return; foreach (long key in stale) drawStates.Remove(key); } public override void OnWorldUnload() { drawStates.Clear(); nextVisualInstanceId = 0; } public override void Unload() { drawStates.Clear(); nextVisualInstanceId = 0; BasicEffect? oldEffect = effect; effect = null; layerVertices = []; indices = []; rimVertices = []; if (oldEffect is not null && !Main.dedServ) Main.QueueMainThreadAction(oldEffect.Dispose); } public override void PostDrawTiles() { if (Main.gameMenu || drawStates.Count == 0 || Main.graphics?.GraphicsDevice is not GraphicsDevice graphicsDevice) { return; } effect ??= new BasicEffect(graphicsDevice) { TextureEnabled = true, VertexColorEnabled = true, LightingEnabled = false, FogEnabled = false }; effect.World = Main.GameViewMatrix.TransformationMatrix; effect.View = Matrix.Identity; effect.Projection = Matrix.CreateOrthographicOffCenter( 0f, Main.screenWidth, Main.screenHeight, 0f, 0f, 1f); graphicsDevice.BlendState = BlendState.AlphaBlend; graphicsDevice.DepthStencilState = DepthStencilState.None; graphicsDevice.RasterizerState = RasterizerState.CullNone; graphicsDevice.SamplerStates[0] = SamplerState.LinearWrap; foreach ((long visualInstanceId, CrescentDrawState state) in drawStates) { if (Main.GameUpdateCount > state.UpdateTick + ResidualLifetimeFrames) continue; BuildVertices(state); DrawBackdropLayers(graphicsDevice); DrawStableRim(graphicsDevice, visualInstanceId, state); } } private static void DrawBackdropLayers(GraphicsDevice graphicsDevice) { if (effect is null) return; for (int layer = 0; layer < 3; layer++) { effect.Texture = DeathDomainBackdropTextureSystem.GetLayer(layer); VertexPositionColorTexture[] vertices = layerVertices[layer]; foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Apply(); graphicsDevice.DrawUserIndexedPrimitives( PrimitiveType.TriangleList, vertices, 0, vertices.Length, indices, 0, indices.Length / 3); } } } private static void BuildVertices(CrescentDrawState state) { // The frontier uses the same eased 0..1 clock as the held blade. Revealing // the whole face at 42% left the weapon floating far behind its hot edge. float reveal = Smooth01(state.LifeProgress); float residualProgress = GetResidualProgress(state); float erosion = Smooth01(residualProgress); float rotationCos = (float)Math.Cos(state.Rotation); float rotationSin = (float)Math.Sin(state.Rotation); int vertexIndex = 0; for (int segment = 0; segment <= AngularSegments; segment++) { float progress = segment / (float)AngularSegments; float angle = MathHelper.Lerp(-HalfSweep, HalfSweep, progress) * state.SwingDirection; float taper = GetCrescentTaper(progress); // The textured interior and the separately drawn hot blade edge must // share this exact boundary. Noise belongs inside the material; // perturbing the outer radius creates a visible air gap at the rim. float outerRadius = state.Radius * OuterRadiusRatio; float innerDistortion = GetInnerBoundaryNoise(state, progress, taper); float thickness = state.Radius * MaximumThicknessRatio * taper; float innerRadius = outerRadius - thickness + innerDistortion; float revealAlpha = 1f - SmoothStep(reveal - 0.024f, reveal + 0.010f, progress); float capAlpha = SmoothStep(0f, 0.018f, progress) * SmoothStep(0f, 0.018f, 1f - progress); Vector2 radial = angle.ToRotationVector2(); for (int band = 0; band <= DepthBands; band++) { float depth = band / (float)DepthBands; float localRadius = MathHelper.Lerp(outerRadius, innerRadius, depth); Vector2 local = radial * localRadius; Vector2 rotated = new( local.X * rotationCos - local.Y * rotationSin, local.X * rotationSin + local.Y * rotationCos); Vector2 world = state.Center + rotated; Vector2 screen = world - Main.screenPosition; float integrity = residualProgress <= 0f || state.PreserveInterior && Main.GameUpdateCount <= state.UpdateTick + 1 ? 1f : ReaperCrescentPrimitiveTextureSystem .SampleDeathInteriorIntegrity(progress, depth, erosion); // The Death Domain is an opaque world-space window. Only the // reveal frontier, interior fracture, and terminal dissolve may // lower alpha; depth never lets the ordinary world bleed through. float alpha = revealAlpha * capAlpha * integrity; // Each parallax layer uses the exact framing and scale of the // Death Necklace. The mask moves, but its domain does not rotate. for (int layer = 0; layer < 3; layer++) { Vector2 uv = DeathDomainBackdropTextureSystem.GetMatchingDomainUv( layer, world, state.Owner, state.Center); layerVertices[layer][vertexIndex] = new VertexPositionColorTexture( new Vector3(screen, 0f), Color.White * (alpha * DeathDomainBackdropTextureSystem .GetLayerOpacity(layer)), uv); } vertexIndex++; } } } private static void DrawStableRim(GraphicsDevice graphicsDevice, long visualInstanceId, CrescentDrawState state) { if (effect is null) return; float residualProgress = GetResidualProgress(state); float opacity = 1f - Smooth01((residualProgress - 0.74f) / 0.26f); if (opacity <= 0.001f) return; DrawRimStrip(graphicsDevice, visualInstanceId, state, state.Radius * 0.030f, new Color(238, 5, 60) * (opacity * 0.82f)); DrawRimStrip(graphicsDevice, visualInstanceId, state, state.Radius * 0.0125f, new Color(255, 225, 220) * (opacity * 0.96f)); } private static void DrawRimStrip(GraphicsDevice graphicsDevice, long visualInstanceId, CrescentDrawState state, float width, Color color) { if (effect is null) return; float reveal = Smooth01(state.LifeProgress); float outerRadius = state.Radius * OuterRadiusRatio; int vertexIndex = 0; for (int segment = 0; segment < AngularSegments; segment++) { float startProgress = segment / (float)AngularSegments; float endProgress = (segment + 1f) / AngularSegments; float middleProgress = (startProgress + endProgress) * 0.5f; Vector2 overlapProbe = GetCrescentPoint(state, middleProgress, outerRadius - width * 0.55f); if (IsCoveredByAnotherCrescent(overlapProbe, visualInstanceId, width * 0.78f)) continue; Color startColor = color * GetRimVertexAlpha(startProgress, reveal); Color endColor = color * GetRimVertexAlpha(endProgress, reveal); Vector2 outerStart = GetCrescentPoint(state, startProgress, outerRadius); Vector2 innerStart = GetCrescentPoint(state, startProgress, outerRadius - width); Vector2 outerEnd = GetCrescentPoint(state, endProgress, outerRadius); Vector2 innerEnd = GetCrescentPoint(state, endProgress, outerRadius - width); WriteRimVertex(ref vertexIndex, outerStart, startColor); WriteRimVertex(ref vertexIndex, innerStart, startColor); WriteRimVertex(ref vertexIndex, outerEnd, endColor); WriteRimVertex(ref vertexIndex, outerEnd, endColor); WriteRimVertex(ref vertexIndex, innerStart, startColor); WriteRimVertex(ref vertexIndex, innerEnd, endColor); } if (vertexIndex < 3) return; effect.TextureEnabled = false; foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Apply(); graphicsDevice.DrawUserPrimitives(PrimitiveType.TriangleList, rimVertices, 0, vertexIndex / 3); } effect.TextureEnabled = true; } private static float GetRimVertexAlpha(float progress, float reveal) { float revealAlpha = 1f - SmoothStep(reveal - 0.024f, reveal + 0.010f, progress); float capAlpha = SmoothStep(0f, 0.018f, progress) * SmoothStep(0f, 0.018f, 1f - progress); return revealAlpha * capAlpha; } private static Vector2 GetCrescentPoint(CrescentDrawState state, float progress, float radius) { float angle = MathHelper.Lerp(-HalfSweep, HalfSweep, progress) * state.SwingDirection + state.Rotation; return state.Center + angle.ToRotationVector2() * radius; } private static void WriteRimVertex(ref int vertexIndex, Vector2 world, Color color) { rimVertices[vertexIndex++] = new VertexPositionColorTexture( new Vector3(world - Main.screenPosition, 0f), color, Vector2.Zero); } private static bool IsCoveredByAnotherCrescent(Vector2 world, long currentVisualInstanceId, float sharedBoundaryTolerance) { foreach ((long otherId, CrescentDrawState other) in drawStates) { if (otherId == currentVisualInstanceId || Main.GameUpdateCount > other.UpdateTick + ResidualLifetimeFrames) { continue; } if (ContainsVisibleDomain(other, world, preferOtherAtSharedBoundary: otherId > currentVisualInstanceId, sharedBoundaryTolerance)) { return true; } } return false; } private static bool ContainsVisibleDomain(CrescentDrawState state, Vector2 world, bool preferOtherAtSharedBoundary, float sharedBoundaryTolerance) { Vector2 delta = world - state.Center; float rotationCos = (float)Math.Cos(state.Rotation); float rotationSin = (float)Math.Sin(state.Rotation); Vector2 local = new( delta.X * rotationCos + delta.Y * rotationSin, -delta.X * rotationSin + delta.Y * rotationCos); float radius = local.Length(); float angle = MathHelper.WrapAngle(local.ToRotation()); float progress = state.SwingDirection > 0 ? (angle + HalfSweep) / (HalfSweep * 2f) : (HalfSweep - angle) / (HalfSweep * 2f); if (progress is < 0f or > 1f || progress > Smooth01(state.LifeProgress) + 0.010f) { return false; } float taper = GetCrescentTaper(progress); float outerRadius = state.Radius * OuterRadiusRatio; float innerDistortion = GetInnerBoundaryNoise(state, progress, taper); float innerRadius = outerRadius - state.Radius * MaximumThicknessRatio * taper + innerDistortion; if (radius < innerRadius - 1f || radius > outerRadius + 1f) return false; // Strictly interior overlap always removes the internal edge. Coincident // or near-coincident rims use instance order so exactly one copy remains; // without this tolerance, both ribbons would classify each other's // inward probe as interior and erase the shared exterior entirely. float boundaryDepth = outerRadius - radius; return boundaryDepth > sharedBoundaryTolerance || preferOtherAtSharedBoundary; } private static float GetCrescentTaper(float progress) { float taper = (float)Math.Pow(Math.Max(0f, Math.Sin(progress * MathHelper.Pi)), 0.67f); return taper * (1f + 0.22f * (progress * 2f - 1f)); } private static float GetInnerBoundaryNoise(CrescentDrawState state, float progress, float taper) { // Continuous, layered wave noise keeps the centre-facing edge organic // without the disconnected saw teeth produced by per-segment randomness. float seed = state.Rotation * 1.37f + state.Center.X * 0.0013f + state.Center.Y * 0.0019f; float wave = (float)Math.Sin(progress * MathHelper.TwoPi * 5f + seed) + (float)Math.Sin(progress * MathHelper.TwoPi * 11f - seed * 0.71f) * 0.46f + (float)Math.Sin(progress * MathHelper.TwoPi * 23f + seed * 1.19f) * 0.19f; return wave * state.Radius * 0.032f * taper; } private static float GetResidualProgress(CrescentDrawState state) { ulong age = Main.GameUpdateCount > state.UpdateTick ? Main.GameUpdateCount - state.UpdateTick : 0; if (age <= 1) return 0f; return MathHelper.Clamp((age - 1f) / (ResidualLifetimeFrames - 1f), 0f, 1f); } private static short[] CreateIndices() { short[] result = new short[AngularSegments * DepthBands * 6]; int index = 0; int stride = DepthBands + 1; for (int segment = 0; segment < AngularSegments; segment++) { for (int band = 0; band < DepthBands; band++) { short topLeft = (short)(segment * stride + band); short bottomLeft = (short)(topLeft + 1); short topRight = (short)(topLeft + stride); short bottomRight = (short)(topRight + 1); result[index++] = topLeft; result[index++] = bottomLeft; result[index++] = topRight; result[index++] = topRight; result[index++] = bottomLeft; result[index++] = bottomRight; } } return result; } private static float SmoothStep(float start, float end, float value) { if (end <= start) return value >= end ? 1f : 0f; return Smooth01((value - start) / (end - start)); } private static float Smooth01(float value) { value = MathHelper.Clamp(value, 0f, 1f); return value * value * (3f - 2f * value); } }