SbokuBase.cs
using Sandbox.AI.Default;
using Sandbox.Sboku.Shared;
using Sandbox.Shared;
using Sandbox.VR;
using System;
using System.Collections.Generic;
using System.Linq;

namespace Sandbox.Sboku;
public abstract class SbokuBase : Component, ISbokuBot
{
    public bool IsRagdoll { get; private set; }

    public void EnterRagdoll()
    {
        IsRagdoll = true;
    }
    public bool ragdollPhysicsPending;
    [Group("Controller")]
    [Property]
    public float Velocity { get; set; } = 160f;
    [Group("Controller")]
    [Property]
    public float Friction { get; set; } = 4.0f;
    [Group("Controller")]
    [Property]
    public float MaxForce { get; set; } = 50f;
    [Group("Controller")]
    [Property]
    public float AirControl { get; set; } = 0.1f;
    [Group("Controller")]
    [Property]
    public CharacterController Character { get; set; }

    [Group("AI")]
    [Property]
    [Range(100, 5000, step: 100)]
    public int SearchRange { get; set; } = 2000;
    [Group("AI")]
    [Property]
    [Range(100, 5000, step: 100)]
    public int MinFightRange { get; set; } = 400;
    [Group("AI")]
    [Property]
    [Range(100, 5000, step: 100)]
    public int MaxFightRange { get; set; } = 600;
    /// <summary>
    /// If true, the bot won't make any new decisions
    /// </summary>
    [Group("AI")]
    [Property]
    public bool IsOffline { get; set; } = false;

    /// <summary>
    /// The duration of a single firing burst.
    /// </summary>
    [Group("Combat")]
    [Property]
    public float BurstPeriod { get; set; } = 0.5f;
    /// <summary>
    /// The duration of a single firing burst.
    /// </summary>
    [Group("Combat")]
    [Property]
    [Range(1, 20, step: 1)]
    public int AimSpeed { get; set; } = 6;

    public int DistanceToRecalucaltePath { get => MinFightRange / 2; }
    public float ThinkingInterval { get => Settings.ThinkingInterval; }
    public abstract Angles EyeAngles { get; set; }
    public abstract Vector3 EyePos { get; }

    /// <summary>
    /// A point in space the bot is navigating toward
    /// </summary>
    public Vector3? Destination { get; private set; }

    /// <summary>
    /// Target the bot must attack
    /// </summary>
    public ISbokuTarget Target { get; set; } = null;

    /// <summary>
    /// Active weapon of the bot
    /// </summary>
    public abstract ISbokuWeapon Weapon { get; }

    public bool IsShooting { get; set; }

    public bool IsNavigating { get => path != null; }
    /// <summary>
    /// Height bots will aim at
    /// </summary>
    public Vector3 HeightToAimAt { get => Target != null ? Vector3.Zero.WithZ(Target.CharacterController.Height * 2 / 3) : Vector3.Zero; }

    /// <summary>
    /// You must set it to true to reload and then manually unset. This way is more robust due to the way SWB works.
    /// </summary>
    public bool IsReloading { get; set; }

    public SbokuSettings Settings { get; private set; }

    private List<Vector3> path;
    private int pathEnumerator;

    #region States

    protected Dictionary<Type, ISbokuState> States { get; private set; }
    private IActionState actionState;
    private ICombatState combatState;
    private List<ISbokuCondition> conditions;

    protected bool MovementEnabled = true;
    public void SetActionState<T>() where T : IActionState
    {
        var state = (IActionState)States[typeof(T)];
        actionState?.OnUnset();
        state.OnSet();
        actionState = state;
    }
    public void SetCombatState<T>() where T : ICombatState
    {
        var state = (ICombatState)States[typeof(T)];
        combatState?.OnUnset();
        state.OnSet();
        combatState = state;
    }
    public bool IsActiveActionState<T>() where T : IActionState
        => actionState.GetType() == typeof(T);
    public bool IsActiveCombatState<T>() where T : ICombatState
        => combatState.GetType() == typeof(T);
    #endregion

    public SbokuBase()
    {
        States = GetStates();
        conditions = GetConditions();

        if (States.ContainsKey(typeof(IdleActionState)))
            SetActionState<IdleActionState>();

        if (States.ContainsKey(typeof(IdleCombatState)))
            SetCombatState<IdleCombatState>();
    }

    protected virtual Dictionary<Type, ISbokuState> GetStates()
        => new()
        {
            { typeof(IdleActionState), new IdleActionState(this) },
            { typeof(ChaseState), new ChaseState(this) },
            { typeof(TacticalState), new TacticalState(this) },
            { typeof(ShootState), new ShootState(this) },
            { typeof(IdleCombatState), new IdleCombatState(this) },
            { typeof(ReloadState), new ReloadState(this) },
        };

    protected virtual List<ISbokuCondition> GetConditions()
        => Conditions.Get(this);

    public void ResetState()
    {
        Target = null;
        Destination = null;
        SetActionState<IdleActionState>();
        SetCombatState<IdleCombatState>();
    }

    #region Component events

    private TimerHelper timer = new();
    private object TimerHandler;
    protected override void OnEnabled()
    {
        if (MinFightRange > MaxFightRange)
        {
            Log.Error("Min fight range is supposed to be less than MaxFightRange");
        }

        if (IsProxy)
            return;

        TimerHandler = timer.Every(ThinkingInterval, OnStateExecute);
    }
    protected override void OnDisabled()
    {
        ResetState();

        if (TimerHandler is not null)
            timer.Remove(TimerHandler);
    }
    protected override void OnAwake()
    {
        Settings = SbokuSettings.CreateOrFind(Scene);

        if (Character == null)
        {
            Character = AddComponent<CharacterController>();
        }

        if (!Scene.NavMesh.IsEnabled)
        {
            Enabled = false;
            Log.Error("NavMesh must be enabled");
        }
    }
    protected void OnStateExecute()
    {
        if (IsRagdoll)
            return;

        if (IsOffline || IsProxy || Scene.NavMesh.IsGenerating) return;

        foreach (var cond in conditions)
        {
            if (cond.If())
            {
                cond.Then();
                if (cond.IsTerminal())
                    break;
            }
        }

        actionState.Think();
        combatState.Think();
    }
    protected override void OnUpdate()
    {
        if (IsProxy)
            return;

        timer.OnUpdate();
    }
    protected override void OnFixedUpdate()
    {
        if (IsRagdoll) return;
        if (IsProxy)
            return;

        // Ei kohdetta => älä ammu
        if (Target == null)
        {
            IsShooting = false;
            return;
        }

        // 🔒 Estä ampuminen seinän läpi (TÄMÄ ON OIKEIN)
        if (Target != null && !HasLineOfSight(Target))
        {
            IsShooting = false;
        }

        

        // 🔑 PYSÄYTYS MIN FIGHT RANGESSA
        if (Target is ISbokuTarget tgt)
        {
            var dist = Vector3.DistanceBetween(
                Character.WorldPosition.WithZ(0),
                tgt.GameObject.WorldPosition.WithZ(0)
            );

            if (dist < MinFightRange)
            {
                StopNavigating();
                Move(Vector3.Zero);
                UpdateAnimations(Vector3.Zero, Character.WorldRotation);
                return;
            }
        }

        // ❌ POISTETTU:
        // MoveTo(LastSeenTargetPos) EI KUULU OnFixedUpdate():en

        if (path is not null)
        {
            if (Settings.ShowDebugOverlay)
            {
                foreach (var p in path)
                    Scene.DebugOverlay.Sphere(new Sphere(p, 10), Color.Yellow, 1);
            }

            if (Vector3.DistanceBetweenSquared(
                Character.WorldPosition.WithZ(0),
                Destination.Value.WithZ(0)
            ) < MathF.Pow(Scene.NavMesh.AgentRadius, 2))
            {
                pathEnumerator++;
                if (pathEnumerator < path.Count)
                    Destination = path[pathEnumerator];
                else
                {
                    path = null;
                    Destination = null;
                    pathEnumerator = 0;
                }
            }
        }

        var vector = Vector3.Zero;

        if (Destination is Vector3 dest)
        {
            var direction = dest - Character.WorldPosition;
            float yaw = MathF.Atan2(direction.y, direction.x).RadianToDegree();
            vector = direction.WithZ(0).Normal;
            Rotate(yaw);
        }

        if (Target is ISbokuTarget ply)
        {
            var perfectAimDirection =
                (ply.GameObject.WorldPosition + HeightToAimAt - EyePos).Normal;

            var newAimDirection = Vector3.Slerp(
                EyeAngles.Forward,
                perfectAimDirection,
                AimSpeed * Time.Delta
            );

            var newYaw = MathF.Atan2(newAimDirection.y, newAimDirection.x).RadianToDegree();
            Rotate(newYaw);

            var horizontalLength = MathF.Sqrt(
                newAimDirection.x * newAimDirection.x +
                newAimDirection.y * newAimDirection.y
            );

            var newPitch = -MathF.Atan2(newAimDirection.z, horizontalLength).RadianToDegree();
            EyeAngles = new Angles(newPitch, newYaw, 0);
        }

        Move(vector);
        UpdateAnimations(vector, Character.WorldRotation);
    }

    protected override void DrawGizmos()
    {
        base.DrawGizmos();

        Gizmo.Draw.Color = Color.Green;
        Gizmo.Draw.LineCircle(Vector3.Zero, Vector3.Up, SearchRange);
        Gizmo.Draw.Color = Color.Orange;
        Gizmo.Draw.LineCircle(Vector3.Zero, Vector3.Up, MinFightRange);
        Gizmo.Draw.Color = Color.Red;
        Gizmo.Draw.LineCircle(Vector3.Zero, Vector3.Up, MaxFightRange);
    }

    #endregion

    /// <summary>
    /// Try to move in the direction given by the wishVelocity unit vector
    /// </summary>
    /// <param name="wishVelocity"></param>
    protected virtual void Move(Vector3 wishVelocity)
    {
        if (IsRagdoll)
            return;

        var vel = wishVelocity * Velocity;
        var gravity = Scene.PhysicsWorld.Gravity;
        if (Character.IsOnGround)
        {
            Character.Velocity = Character.Velocity.WithZ(0);
            Character.Accelerate(vel);
            Character.ApplyFriction(Friction);
        }
        else
        {
            Character.Velocity += gravity * Time.Delta * 0.5f;
            Character.Accelerate(vel.ClampLength(MaxForce));
            Character.ApplyFriction(AirControl);
        }

        if (!(Character.Velocity.IsNearZeroLength && vel.IsNearZeroLength))
        {
            Character.Move();
        }
    }

    /// <summary>
    /// Set rotation based on the yaw
    /// </summary>
    /// <param name="yaw"></param>
    private void Rotate(float yaw)
    {
        if (IsRagdoll)
            return;

        GameObject.WorldRotation = Rotation.FromYaw(yaw);
    }

    [Rpc.Broadcast]
    protected abstract void UpdateAnimations(Vector3 WishVelocity, Rotation rotation);

    /// <summary>
    /// Navigate to the position
    /// </summary>
    /// <param name="targetPosition"></param>
    public void MoveTo(Vector3 targetPosition)
    {
        if (IsRagdoll)
            return;

        var nav = Scene.NavMesh;
        if (nav == null || !nav.IsEnabled || nav.IsGenerating)
            return;

        // Botin jalat
        Vector3 feetPos =
            Character.WorldPosition
            - Vector3.Up * (Character.Height * 0.5f);

        Vector3? startOpt = null;

        // 🔽 ENSISIJAINEN: etsi suoraan alaspäin
        for (int i = 0; i < 5; i++)
        {
            var probe = feetPos + Vector3.Up * (i * 50f);
            startOpt = nav.GetClosestPoint(probe, 1000f);

            if (startOpt.HasValue)
                break;
        }

        if (startOpt is not Vector3 start)
        {
            Log.Info($"Bot Not in NavMesh. Bot: XY = {Transform.Position.x}, {Transform.Position.y}");
            return;
        }

        // Snap oikeasti NavMeshille
        Transform.Position = start;

        Vector3? endOpt = nav.GetClosestPoint(targetPosition, 1000f);
        if (endOpt is not Vector3 end)
            return;

        var newPath = nav.GetSimplePathSafe(start, end);
        if (newPath == null || newPath.Count == 0)
            return;

        path = newPath;
        pathEnumerator = 0;
        Destination = path[0];
    }






    /// <summary>
    /// Navigate to the position given the path.
    /// </summary>
    /// <param name="path"></param>
    public void MoveTo(List<Vector3> path)
    {
        if (IsRagdoll)
            return;

        if (!path.Any())
        {
            Log.Info("Path contains no elements");
            return;
        }

        this.path = path;
        pathEnumerator = 0;
        Destination = path[pathEnumerator];
    }
    /// <summary>
    /// Stop moving to the point, given by the MoveTo methods
    /// </summary>
    public void StopNavigating()
    {
        path = null;
        Destination = null;
    }

    public void Reload()
    {
        (combatState as ShootState)?.OnReload();
    }

    public void OnReloadFinish()
    {
        (combatState as ReloadState)?.OnReloadFinish();
    }

    #region LineOfSight

    protected bool HasLineOfSight(ISbokuTarget target)
    {
        if (target?.GameObject == null)
            return false;

        Vector3 start = EyePos;
        Vector3 end = target.GameObject.WorldPosition + HeightToAimAt;
        Vector3 dir = (end - start).Normal;
        float remaining = Vector3.DistanceBetween(start, end);

        const float step = 8f; // pieni siirto eteenpäin passablen jälkeen

        while (remaining > 0)
        {
            var tr = Scene.Trace
                .Ray(start, start + dir * remaining)
                .IgnoreGameObject(GameObject)
               
                .Run();

            if (!tr.Hit)
                return true;

            // 👇 osui pelaajaan
            if (tr.GameObject.Tags.Has("player"))
                return true;

            // 👇 osui läpäistävään → jatka rayta
            if (tr.GameObject.Tags.Has("passable"))
            {
                start = tr.EndPosition + dir * step;
                remaining -= step;
                continue;
            }

            // 👇 seinä / muu blokkaa
            return false;
        }

        return true;
    }

    #endregion


}