XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

DeathMod

【Terraria】DeathMod (now aka Soul Harvest)

公开
关注 0 Fork 0 Star 0
UTF-8
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using System;
using System.Collections.Generic;
using Terraria;
using Terraria.ModLoader;

namespace SoulHarvest.Common;

/// <summary>
/// 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.
/// </summary>
[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<long, CrescentDrawState> 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);

    /// <summary>
    /// 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.
    /// </summary>
    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<long>? 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);
    }
}