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

DeathMod

【Terraria】DeathMod (now aka Soul Harvest)

公开
关注 0 Fork 0 Star 0
UTF-8
using SoulHarvest.Common;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using ReLogic.Content;
using System;
using System.Collections.Generic;
using System.IO;
using Terraria;
using Terraria.ID;
using Terraria.ModLoader;

namespace SoulHarvest.Projectiles;

/// <summary>
/// Shared Blood crescent projectile for primary attacks and the ultimate. Every
/// blade records its real world-space route; that exact route remains as the
/// damaging blood wound instead of being replaced by a guessed straight line.
/// </summary>
public sealed class ReaperBloodBladeProjectile : ModProjectile
{
    private const int MaximumTrailPoints = 240;
    private const int HomingDelayFrames = 10;
    private const float TargetSearchRadius = 560f;
    private const float TargetRetentionRadius = 720f;

    private readonly List<ReaperBloodBladeTrailPoint> trail = [];
    private SickleCombatSnapshot snapshot;
    private ReaperHitKind hitKind;
    private int bladeIndex;
    private int actionId;
    private int targetIndex = -1;
    private int pass;
    private int passTimer;
    private int maximumPasses;
    private int scarLifetime;
    private int activeTimer;
    private bool lingering;
    private bool configured;
    private bool serverAuthorized;

    public override string Texture => "Terraria/Images/Projectile_0";

    internal static int Spawn(Terraria.DataStructures.IEntitySource source, int owner,
        in SickleCombatSnapshot snapshot, Vector2 position, Vector2 velocity,
        int bladeIndex, int actionId, ReaperHitKind hitKind = ReaperHitKind.Ultimate,
        float damageMultiplier = 0.28f, int maximumPasses = 4, int scarLifetime = 180)
    {
        if (Main.netMode == NetmodeID.MultiplayerClient)
            return -1;
        int index = Projectile.NewProjectile(source, position, velocity,
            ModContent.ProjectileType<ReaperBloodBladeProjectile>(),
            Math.Max(1, (int)Math.Round(snapshot.Damage * damageMultiplier)),
            snapshot.Knockback * 0.38f, owner);
        if (index < 0 || index >= Main.maxProjectiles)
            return -1;
        Projectile projectile = Main.projectile[index];
        projectile.originalDamage = snapshot.Damage;
        projectile.CritChance = snapshot.CritChance;
        if (projectile.ModProjectile is ReaperBloodBladeProjectile blade)
            blade.Configure(snapshot, bladeIndex, actionId, hitKind, maximumPasses, scarLifetime);
        projectile.GetGlobalProjectile<MyGlobalProjectile>().ConfigureReaperProjectile(
            projectile, snapshot, hitKind, bladeIndex, velocity.ToRotation(), actionId);
        projectile.localNPCHitCooldown = 24;
        ReaperProjectileHelper.SyncNewProjectile(projectile);
        return index;
    }

    internal static int GetScarLifetime(int skillLevel) => skillLevel switch
    {
        >= 3 => 300,
        2 => 210,
        1 => 150,
        _ => 120
    };

    public override void SetDefaults()
    {
        Projectile.width = 34;
        Projectile.height = 34;
        Projectile.friendly = true;
        Projectile.hostile = false;
        Projectile.tileCollide = false;
        Projectile.ignoreWater = true;
        Projectile.penetrate = -1;
        Projectile.timeLeft = 480;
        Projectile.netImportant = true;
        Projectile.DamageType = DamageClass.Melee;
        Projectile.usesLocalNPCImmunity = true;
        Projectile.localNPCHitCooldown = 24;
    }

    public override bool? CanDamage()
        => configured && (!lingering || trail.Count >= 2) ? null : false;

    public override void AI()
    {
        if (Main.netMode == NetmodeID.Server && !serverAuthorized)
        {
            Projectile.Kill();
            return;
        }
        if (!configured)
            return;

        AgeTrail();
        if (lingering)
        {
            Projectile.velocity = Vector2.Zero;
            Projectile.timeLeft = 2;
            if (trail.Count < 2)
                Projectile.Kill();
            return;
        }

        activeTimer++;
        RecordTrailPoint(Projectile.Center);
        NPC? target = GetOrFindTarget();
        if (target is null)
        {
            Projectile.velocity *= 0.985f;
            Projectile.rotation += 0.32f;
            if (activeTimer >= 70)
                BeginLingering();
            return;
        }

        passTimer++;
        float spacing = MathHelper.TwoPi / Math.Max(3, maximumPasses + 2);
        float angle = bladeIndex * spacing + pass * MathHelper.Pi + (pass & 1) * 0.34f;
        Vector2 attackAxis = angle.ToRotationVector2();
        Vector2 destination = target.Center + attackAxis * 148f;
        Vector2 desired = (destination - Projectile.Center).SafeNormalize(Projectile.velocity)
            * (22f + pass * 1.25f);
        Projectile.velocity = Vector2.Lerp(Projectile.velocity, desired, 0.16f);
        int spinDirection = Math.Sign(Projectile.velocity.X);
        if (spinDirection == 0)
            spinDirection = 1;
        Projectile.rotation += 0.34f * spinDirection;
        Projectile.GetGlobalProjectile<MyGlobalProjectile>()
            .UpdateReaperAttackAngle(Projectile.velocity.ToRotation());

        if (Vector2.DistanceSquared(Projectile.Center, destination) <= 34f * 34f
            || passTimer >= 31)
        {
            CompletePass();
        }
    }

    public override bool? Colliding(Rectangle projHitbox, Rectangle targetHitbox)
    {
        if (!configured)
            return false;
        // The visible head and every already-recorded span are live at the same
        // time. ModifyHitNPC separates their 75%/25% damage budgets.
        if (!lingering && projHitbox.Intersects(targetHitbox))
            return true;
        if (trail.Count < 2)
            return false;
        float collisionPoint = 0f;
        for (int index = 1; index < trail.Count; index++)
        {
            Vector2 start = trail[index - 1].Position;
            Vector2 end = trail[index].Position;
            if (Vector2.DistanceSquared(start, end) > 180f * 180f)
                continue;
            if (Collision.CheckAABBvLineCollision(targetHitbox.TopLeft(), targetHitbox.Size(),
                start, end, 11f, ref collisionPoint))
            {
                return true;
            }
        }
        return false;
    }

    public override void ModifyHitNPC(NPC target, ref NPC.HitModifiers modifiers)
    {
        bool directHeadHit = !lingering && Projectile.Hitbox.Intersects(target.Hitbox);
        if (!directHeadHit && !lingering)
        {
            // The flying actor is configured at 75%; one third of that is the
            // requested 25% base damage for an already-carved trail span.
            modifiers.SourceDamage *= 1f / 3f;
        }
    }

    public override void OnHitNPC(NPC target, NPC.HitInfo hit, int damageDone)
    {
        bool directHeadHit = !lingering && Projectile.Hitbox.Intersects(target.Hitbox);
        // Residual ticks use a shorter gate so a target can still be struck by the
        // approaching 75% blade head after touching an older crossing trail.
        Projectile.localNPCImmunity[target.whoAmI] = directHeadHit ? 24 : 8;
    }

    public override bool PreDraw(ref Color lightColor)
    {
        if (!configured || Main.dedServ)
            return false;

        DrawRecordedBloodScar();
        if (lingering)
            return false;

        // Reuse the original primary-attack crescent projectile artwork. This is
        // an energy blade, not a duplicated Blood scythe weapon model.
        Asset<Texture2D> asset = ModContent.Request<Texture2D>(
            "SoulHarvest/Projectiles/BloodCrescentProjectile");
        Texture2D texture = asset.Value;
        Vector2 origin = texture.Size() * 0.5f;
        float rotation = Projectile.rotation;
        Main.EntitySpriteDraw(texture, Projectile.Center - Main.screenPosition, null,
            new Color(245, 25, 55, 0) * 0.38f, rotation, origin, 1.10f,
            SpriteEffects.None);
        Main.EntitySpriteDraw(texture, Projectile.Center - Main.screenPosition, null,
            Color.White, rotation, origin, 0.92f, SpriteEffects.None);
        return false;
    }

    public override void SendExtraAI(BinaryWriter writer)
    {
        writer.Write(configured);
        if (!configured)
            return;
        snapshot.Write(writer);
        writer.Write((byte)hitKind);
        writer.Write((byte)Math.Clamp(bladeIndex, 0, byte.MaxValue));
        writer.Write(actionId);
        writer.Write((short)targetIndex);
        writer.Write((byte)Math.Clamp(pass, 0, byte.MaxValue));
        writer.Write((byte)Math.Clamp(passTimer, 0, byte.MaxValue));
        writer.Write((byte)Math.Clamp(maximumPasses, 1, byte.MaxValue));
        writer.Write((short)Math.Clamp(scarLifetime, 1, short.MaxValue));
        writer.Write((short)Math.Clamp(activeTimer, 0, short.MaxValue));
        writer.Write(lingering);
    }

    public override void ReceiveExtraAI(BinaryReader reader)
    {
        bool incoming = reader.ReadBoolean();
        if (!incoming)
        {
            if (Main.netMode != NetmodeID.Server)
                configured = false;
            return;
        }
        SickleCombatSnapshot incomingSnapshot = SickleCombatSnapshot.Read(reader);
        ReaperHitKind incomingHitKind = (ReaperHitKind)reader.ReadByte();
        int incomingBlade = reader.ReadByte();
        int incomingAction = reader.ReadInt32();
        int incomingTarget = reader.ReadInt16();
        int incomingPass = reader.ReadByte();
        int incomingPassTimer = reader.ReadByte();
        int incomingMaximumPasses = reader.ReadByte();
        int incomingScarLifetime = reader.ReadInt16();
        int incomingActiveTimer = reader.ReadInt16();
        bool incomingLingering = reader.ReadBoolean();
        if (Main.netMode == NetmodeID.Server)
            return;
        snapshot = incomingSnapshot;
        hitKind = incomingHitKind;
        bladeIndex = incomingBlade;
        actionId = incomingAction;
        targetIndex = incomingTarget;
        pass = incomingPass;
        passTimer = incomingPassTimer;
        maximumPasses = Math.Max(1, incomingMaximumPasses);
        scarLifetime = incomingScarLifetime;
        activeTimer = incomingActiveTimer;
        lingering = incomingLingering;
        configured = true;
    }

    private void Configure(in SickleCombatSnapshot value, int index, int parentActionId,
        ReaperHitKind kind, int passCount, int residualLifetime)
    {
        snapshot = value;
        hitKind = kind;
        bladeIndex = Math.Max(0, index);
        actionId = parentActionId;
        targetIndex = -1;
        pass = 0;
        passTimer = 0;
        maximumPasses = Math.Clamp(passCount, 1, 8);
        scarLifetime = Math.Clamp(residualLifetime, 60, 300);
        activeTimer = 0;
        lingering = false;
        trail.Clear();
        RecordTrailPoint(Projectile.Center);
        configured = true;
        serverAuthorized = Main.netMode != NetmodeID.MultiplayerClient;
    }

    private NPC? GetOrFindTarget()
    {
        if (targetIndex >= 0 && targetIndex < Main.maxNPCs)
        {
            NPC current = Main.npc[targetIndex];
            if (ReaperTargeting.IsValidWeaponTarget(current)
                && (ReaperTargeting.IsTrainingDummy(current)
                    || current.CanBeChasedBy(Projectile))
                && Vector2.DistanceSquared(Projectile.Center, current.Center)
                    <= TargetRetentionRadius * TargetRetentionRadius)
                return current;
            targetIndex = -1;
        }

        // Give the blade a readable launch phase, then acquire only from a local
        // bubble centred on its current position. It no longer turns immediately
        // toward an enemy which happened to exist far across the screen at spawn.
        if (activeTimer < HomingDelayFrames)
            return null;

        NPC? target = ReaperProjectileHelper.FindTarget(Projectile,
            TargetSearchRadius);
        targetIndex = target?.whoAmI ?? -1;
        if (Main.netMode != NetmodeID.MultiplayerClient)
            Projectile.netUpdate = true;
        return target;
    }

    private void CompletePass()
    {
        pass++;
        passTimer = 0;
        Projectile.netUpdate = true;
        if (pass >= maximumPasses)
            BeginLingering();
    }

    private void BeginLingering()
    {
        if (lingering)
            return;
        lingering = true;
        Projectile.velocity = Vector2.Zero;
        Projectile.damage = Math.Max(1, (int)Math.Round(snapshot.Damage * 0.25f));
        Projectile.localNPCHitCooldown = 30;
        Array.Fill(Projectile.localNPCImmunity, 0);
        Projectile.netUpdate = true;
    }

    private void RecordTrailPoint(Vector2 position)
    {
        if (!float.IsFinite(position.X) || !float.IsFinite(position.Y))
            return;
        if (trail.Count > 0)
        {
            float distanceSquared = Vector2.DistanceSquared(trail[^1].Position, position);
            if (distanceSquared < 4f * 4f)
            {
                trail[^1].Position = position;
                trail[^1].Age = 0;
                return;
            }
            if (distanceSquared > 200f * 200f)
            {
                trail.Add(new ReaperBloodBladeTrailPoint { Position = position, Age = 0 });
                if (trail.Count > MaximumTrailPoints)
                    trail.RemoveAt(0);
                return;
            }
        }
        trail.Add(new ReaperBloodBladeTrailPoint { Position = position, Age = 0 });
        if (trail.Count > MaximumTrailPoints)
            trail.RemoveAt(0);
    }

    private void AgeTrail()
    {
        for (int index = trail.Count - 1; index >= 0; index--)
        {
            if (++trail[index].Age > scarLifetime)
                trail.RemoveAt(index);
        }
    }

    private void DrawRecordedBloodScar()
    {
        if (trail.Count < 2)
            return;
        int count = trail.Count;
        for (int index = 1; index < count; index++)
        {
            ReaperBloodBladeTrailPoint older = trail[index - 1];
            ReaperBloodBladeTrailPoint newer = trail[index];
            if (Vector2.DistanceSquared(older.Position, newer.Position) > 180f * 180f)
                continue;
            float progress = (index - 0.5f) / Math.Max(1f, count - 1f);
            float taper = (float)Math.Pow(Math.Max(0f, Math.Sin(progress * MathHelper.Pi)), 0.38f);
            float age = Math.Max(older.Age, newer.Age);
            float fade = 1f - Smooth01((age - scarLifetime * 0.72f)
                / Math.Max(1f, scarLifetime * 0.28f));
            float wave = 1f + (float)Math.Sin(progress * 31f + actionId * 0.071f) * 0.07f;
            float width = Math.Max(0.7f, 14f * taper * wave);
            DrawScarStrip(older.Position, newer.Position, width, fade);
        }
    }

    private static void DrawScarStrip(Vector2 start, Vector2 end, float width, float opacity)
        => ReaperBloodTrailRenderer.DrawSegment(start, end, width, opacity);

    private static float Smooth01(float value)
    {
        value = MathHelper.Clamp(value, 0f, 1f);
        return value * value * (3f - 2f * value);
    }
}