ArenaShield.cs
using Sandbox;
using System;
using Sandbox.Citizen;
namespace PaintballBaddies;

/// <summary>Two reusable clear shields shared by all competitors.</summary>
public sealed class ArenaShield : Component
{
    public const int Capacity=5;
    public const int MeleeDamage=(Capacity+1)/2;
    public enum MeleeResult { None, Absorbed, Broken }
    [Sync(SyncFlags.FromHost)] public int Remaining { get; private set; }=Capacity;
    [Sync(SyncFlags.FromHost)] public PaintballCombatant Owner { get; private set; }
    private GameObject panel;
    private ModelRenderer cracks;
    private int lastRemaining=Capacity;
    private float hitKick;
    public int Shatters {get;private set;}
    [Sync(SyncFlags.FromHost)] public float RespawnDelay {get;private set;}
    [Sync(SyncFlags.FromHost)] public Transform PickupTransform {get;private set;}
    [Sync(SyncFlags.FromHost)] public string PaintHistory {get;private set;}="";
    private string receivedPaint;
    private readonly List<ShieldMark> history=new();
    private sealed class ShieldMark {public Vector3 Point {get;set;} public Color Tint {get;set;}}
    private readonly Random random=new();
    private Vector3 lastSpawn;
    private bool ready,wasHeld;
    private IDisposable carryHook;
    private SkinnedModelRenderer carryBody;
    private readonly Queue<GameObject> paint=new();
    public int PaintCount=>paint.Count;
    public float GripError { get; private set; }
    public bool Stowed => Owner.IsValid() && (Owner.Components.Get<CloseCombat>() is {Incapacitated:true} || Owner.Components.Get<CloseCombat>() is {Attacking:true} || Owner.Components.Get<CoverController>() is {Attached:true,Peeking:false}
        || Owner.Components.Get<CoverController>()?.Sliding==true || Owner.Components.Get<VaultController>()?.IsVaulting==true
        || Owner.Components.Get<ArenaOpponent>()?.UsingCover==true);
    private static Vector3 Handle => new(-5,8,0);
    public void ApplyGrip(SkinnedModelRenderer body,CitizenAnimationHelper animation)
    {
        carryBody=body;
        if(!Stowed)animation.IkLeftHand=null; // Native left-hand carry pose owns this arm.
    }
    public void AddPaint(SceneTraceResult hit,Color tint)
    {
        if(!MultiplayerSession.Authority)return;
        var record=new ShieldMark {Point=WorldTransform.PointToLocal(hit.HitPosition),Tint=tint};
        if(MultiplayerSession.Online){history.Add(record);while(history.Count>Capacity)history.RemoveAt(0);PaintHistory=Json.Serialize(history);}
        ApplyPaint(record);
    }
    private void ApplyPaint(ShieldMark record)
    {
        while(paint.Count>=Capacity)paint.Dequeue()?.Destroy();
        var patch=new GameObject(GameObject){Name="Shield paint splatter",NetworkMode=NetworkMode.Never};patch.Tags.Add("paintball_debris");
        var local=record.Point;
        float y=local.y.Clamp(-11,11);float angle=MathF.Asin(y/30);
        var normal=new Vector3(MathF.Cos(angle),MathF.Sin(angle),0);
        patch.LocalPosition=new Vector3(30*(MathF.Cos(angle)-1),y,local.z.Clamp(-17,17))+normal*.46f;
        patch.LocalRotation=Rotation.FromYaw(angle*180/MathF.PI);
        var renderer=patch.Components.Create<ModelRenderer>();renderer.Model=Model.Load("models/gear/clear_shield/paint_patch.vmdl");
        renderer.MaterialOverride=Material.Load("models/gear/clear_shield/paint.vmat");renderer.Tint=record.Tint;
        float splashScale=PaintImpactSystem.SplashScale;
        // Preserve the shallow curved patch's fit as its surface area grows.
        patch.LocalScale=new Vector3(splashScale*splashScale,splashScale,splashScale);
        paint.Enqueue(patch);
    }
    protected override void OnStart()
    {
        // A held shield follows each peer's displayed hand. Streaming a second
        // world transform fights that attachment and makes it trail the wearer.
        if(MultiplayerSession.Authority)GameObject.Network.Flags|=NetworkFlags.NoTransformSync;
        panel=new GameObject(GameObject){Name="Clear curved polycarbonate shield",NetworkMode=NetworkMode.Never};
        panel.Components.Create<ModelRenderer>().Model=Model.Load("models/gear/clear_shield/panel.vmdl");
        var crackObject=new GameObject(GameObject){Name="Shield fracture seams",NetworkMode=NetworkMode.Never};
        cracks=crackObject.Components.Create<ModelRenderer>();cracks.Model=Model.Load("models/gear/clear_shield/fracture/cracks.vmdl");
        cracks.Tint=new Color(.8f,.91f,.96f);cracks.Enabled=false;
        var rim=new GameObject(GameObject){Name="Rounded shield rim",NetworkMode=NetworkMode.Never};
        rim.Components.Create<ModelRenderer>().Model=Model.Load("models/gear/clear_shield/rim.vmdl");
        TrainingRange.Box(GameObject,"Shield forearm grip",new(-5,8,0),new(1,9,1.5f),new Color(.12f,.15f,.17f));
        foreach(float side in new[]{-1f,1f})TrainingRange.Box(GameObject,"Shield handle bracket",new(-2.5f,8+side*4,0),new(5,.8f,.8f),new Color(.12f,.15f,.17f));
        foreach(var child in GameObject.Children)child.NetworkMode=NetworkMode.Never;
        ready=true;if(MultiplayerSession.Authority)Respawn();
        carryHook=Scene.AddHook(GameObjectSystem.Stage.FinishUpdate,100,()=>
        {
            if(ready && Owner.IsValid())FollowOwner();
        },nameof(ArenaShield),"Align shield with final displayed hand");
    }
    private void Respawn()
    {
        Owner=null;if(!MultiplayerSession.Online)GameObject.Parent=null;Remaining=Capacity;lastRemaining=Capacity;hitKick=0;
        var authoredSpawns=ArenaMap.ShieldSpawns(Scene);
        // Authored points support raised floors and other arenas. The original map
        // retains its random floor search when no pickup markers have been placed.
        for(int attempt=0;attempt<100;attempt++)
        {
            var anchor=authoredSpawns.Length>0 ? authoredSpawns[random.Next(authoredSpawns.Length)].WorldPosition
                : new Vector3(random.Next(-950,950),random.Next(-650,1400),0);
            var point=anchor+Vector3.Up*100;
            var floor=Scene.Trace.Ray(point,anchor-Vector3.Up*20).IgnoreGameObjectHierarchy(GameObject).Run();
            if(!floor.Hit || floor.Normal.z<.95f || floor.HitPosition.z>anchor.z+8)continue;
            point=floor.HitPosition+Vector3.Up*24;
            if((point-lastSpawn).Length<180)continue;
            if(Scene.GetAllComponents<ArenaShield>().Any(x=>x!=this&&(x.WorldPosition-point).Length<180))continue;
            if(Scene.Trace.Sphere(23,point,point+Vector3.Up*24).IgnoreGameObjectHierarchy(GameObject).Run().Hit)continue;
            WorldPosition=point;WorldRotation=Rotation.FromYaw(random.Next(360));lastSpawn=point;PickupTransform=WorldTransform;
            foreach(var renderer in Components.GetAll<ModelRenderer>(FindMode.EverythingInSelfAndDescendants))renderer.Enabled=true;
            cracks.Enabled=false;
            RespawnDelay=0;return;
        }
        RespawnDelay=1;
    }
    public bool Collect(PaintballCombatant actor)
    {
        if(!MultiplayerSession.Authority || !ready || RespawnDelay>0 || Owner.IsValid() || !actor.IsValid() || actor.Eliminated
            || actor.Components.Get<CloseCombat>()?.Incapacitated==true)return false;
        if(Scene.GetAllComponents<ArenaShield>().Any(x=>x!=this&&x.Owner==actor))return false;
        wasHeld=true;Owner=actor;if(!MultiplayerSession.Online)GameObject.Parent=actor.GameObject;Remaining=Capacity;lastRemaining=Capacity;hitKick=0;
        carryBody=null;
        PlaySound("sounds/paintball/shield_collect.sound",actor.WorldPosition);
        FollowOwner();return true;
    }
    private void FollowOwner()
    {
        // Collection/respawn can change owners before that actor's presentation
        // update calls ApplyGrip. Resolve the current wearer immediately instead
        // of displaying a fallback pose (or the previous wearer's cached hand).
        carryBody=Owner.Components.Get<CitizenPlayerPresentation>()?.FootstepRenderer
            ?? Owner.Components.Get<CitizenOpponentPresentation>()?.FootstepRenderer;
        var player=Owner.Components.Get<PlayerController>();
        var facing=carryBody.IsValid() ? Rotation.FromYaw(carryBody.WorldRotation.Angles().yaw)
            : player.IsValid() ? Rotation.FromYaw(player.EyeAngles.yaw) : Owner.WorldRotation;
        var crouched=player.IsValid() ? player.IsDucking : Owner.Components.Get<ArenaOpponent>()?.CoverCrouched==true;
        var hand=carryBody.IsValid() ? carryBody.GetBoneObject("hand_L") : null;
        bool visible=hand.IsValid() && !Stowed;
        if(visible)
        {
            if(!MultiplayerSession.Online && GameObject.Parent!=hand)GameObject.Parent=hand;
            WorldScale=Vector3.One;WorldRotation=facing*Rotation.FromPitch(MathF.Sin(hitKick/.24f*MathF.PI)*8);
            WorldPosition=hand.WorldPosition-WorldRotation*Handle;
            GripError=(hand.WorldPosition-(WorldPosition+WorldRotation*Handle)).Length;
        }
        else {WorldRotation=facing;WorldPosition=Owner.WorldPosition+facing*new Vector3(20,0,crouched ? 27 : 40);}
        foreach(var renderer in Components.GetAll<ModelRenderer>(FindMode.EverythingInSelfAndDescendants))renderer.Enabled=visible;
        cracks.Enabled=visible && Remaining<=Capacity/2 && Remaining>0;
    }
    protected override void OnUpdate()
    {
        if(!ready)return;
        if(Remaining<lastRemaining && Remaining>0)hitKick=.24f;
        lastRemaining=Remaining;hitKick=MathF.Max(0,hitKick-Time.Delta);
        if(!MultiplayerSession.Authority)
        {
            if(receivedPaint!=PaintHistory)
            {
                while(paint.Count>0)paint.Dequeue()?.Destroy();
                if(!string.IsNullOrEmpty(PaintHistory))foreach(var record in Json.Deserialize<List<ShieldMark>>(PaintHistory))ApplyPaint(record);
                receivedPaint=PaintHistory;
            }
            if(Owner.IsValid())FollowOwner();
            else
            {
                if(Remaining>0)WorldTransform=PickupTransform;
                foreach(var renderer in Components.GetAll<ModelRenderer>(FindMode.EverythingInSelfAndDescendants))renderer.Enabled=Remaining>0;
                cracks.Enabled=false;
            }
            return;
        }
        if(wasHeld && !Owner.IsValid()){Break();return;}
        if(RespawnDelay>0){RespawnDelay-=Time.Delta;if(RespawnDelay<=0)Respawn();return;}
        if(Owner.IsValid())
        {
            if(Owner.Eliminated || Owner.Components.Get<CloseCombat>()?.Incapacitated==true){Break();return;}
            FollowOwner();return;
        }
        foreach(var actor in Scene.GetAllComponents<PaintballCombatant>())
        {
            if((actor.WorldPosition-WorldPosition.WithZ(actor.WorldPosition.z)).Length>40)continue;
            if(Scene.Trace.Ray(actor.EyePosition,WorldPosition).IgnoreGameObjectHierarchy(actor.GameObject).IgnoreGameObjectHierarchy(GameObject).Run().Hit)continue;
            if(Collect(actor))break;
        }
    }
    public void Absorb()
    {
        if(!MultiplayerSession.Authority || !Owner.IsValid() || RespawnDelay>0 || Stowed)return;
        Damage(1,-WorldRotation.Forward);
    }
    private void Damage(int amount,Vector3 direction)
    {
        Remaining=Math.Max(0,Remaining-amount);
        if(Remaining==0)Break(true,direction);
    }
    private bool BlocksFrom(Vector3 source)
        => Owner.IsValid() && Remaining>0 && RespawnDelay<=0 && !Stowed
            && Vector3.Dot((source-Owner.WorldPosition).WithZ(0).Normal,WorldRotation.Forward.WithZ(0).Normal)>.05f;
    public static MeleeResult TryAbsorbMelee(PaintballCombatant actor,Vector3 source)
    {
        if(!MultiplayerSession.Authority || !actor.IsValid())return MeleeResult.None;
        var shield=actor.Scene.GetAllComponents<ArenaShield>().FirstOrDefault(x=>x.Owner==actor && x.BlocksFrom(source));
        if(!shield.IsValid())return MeleeResult.None;
        shield.Damage(MeleeDamage,(actor.WorldPosition-source).WithZ(0).Normal);
        return shield.Remaining==0 ? MeleeResult.Broken : MeleeResult.Absorbed;
    }
    public static void ReleaseForKnockdown(PaintballCombatant actor)
    {
        if(!MultiplayerSession.Authority || !actor.IsValid())return;
        foreach(var shield in actor.Scene.GetAllComponents<ArenaShield>().Where(x=>x.Owner==actor).ToArray())
            shield.Break(); // Forfeit the pickup; actual durability breaks alone shatter it.
    }
    private void Break(bool shatter=false,Vector3 direction=default)
    {
        if(shatter)
        {
            Shatters++;
            int seed=Game.Random.Int(0,1000000);
            if(MultiplayerSession.Online)NetworkEffects.ShieldBreak(WorldTransform,direction,seed);
            else ShieldBreakEffect.Spawn(Scene,WorldTransform,direction,seed);
            PlaySound("sounds/paintball/shield_break.sound",WorldPosition);
        }
        // Detach before the owner is removed at a round boundary.
        if(!MultiplayerSession.Online)GameObject.Parent=null;Owner=null;Remaining=0;RespawnDelay=4;
        while(paint.Count>0)paint.Dequeue()?.Destroy();
        wasHeld=false;carryBody=null;history.Clear();PaintHistory="";
        foreach(var renderer in Components.GetAll<ModelRenderer>(FindMode.EverythingInSelfAndDescendants))renderer.Enabled=false;
        foreach(var decal in Components.GetAll<Decal>(FindMode.EverythingInSelfAndDescendants).ToArray())decal.GameObject.Destroy();
    }
    public static ArenaShield Find(GameObject hit)
    {
        for(var go=hit;go is not null;go=go.Parent)
            if(go.Components.Get<ArenaShield>() is {} shield)return shield;
        return null;
    }
    /// <summary>Route shield hits before either competitor's score or clothing changes.</summary>
    public static bool TryAbsorbImpact(SceneTraceResult hit, Vector3 source, Color tint)
    {
        var shield=Find(hit.GameObject);
        bool physical=shield is not null;
        if(shield is null && PaintballCombatant.Find(hit.GameObject) is {} actor)
        {
            shield=actor.Scene.GetAllComponents<ArenaShield>().FirstOrDefault(s=>s.Owner==actor && s.Remaining>0 && !s.Stowed);
            if(shield is not null)
            {
                // Front half of the wearer. Side/rear shots still tag the player;
                // a visibly stowed shield supplies no protection.
                if(!shield.BlocksFrom(source))shield=null;
            }
        }
        if(shield is null)return false;
        if(physical)PaintImpactSystem.Find(shield.Scene)?.Spawn(hit,tint);
        else
        {
            // The frontal guard also catches a head/leg hit just outside the
            // visible panel. Place its mark on the curved panel, not the wearer.
            shield.AddPaint(hit,tint);
            PlaySound("sounds/paintball/impact.sound",shield.WorldPosition);
        }
        shield.Absorb();
        return true;
    }
    private static void PlaySound(string path,Vector3 position)
    {if(MultiplayerSession.Online)NetworkEffects.Sound(path,position,Guid.Empty);else Sound.Play(path,position);}
    protected override void OnDestroy()=>carryHook?.Dispose();
    public static void ResetArena(Scene scene)
    {
        if(!MultiplayerSession.Authority)return;
        foreach(var shield in scene.GetAllComponents<ArenaShield>().ToArray())shield.GameObject.Destroy();
        for(int i=0;i<2;i++)new GameObject(true,$"Clear shield pickup {i+1}").Components.Create<ArenaShield>();
    }
}