SbokuBase.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox.AI.Default;
using Sandbox.Sboku.Shared;
using Sandbox.Shared;
namespace Sandbox.Sboku;
public abstract class SbokuBase : Component, ISbokuBot
{
[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; } = 1500;
[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; } = 8;
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;
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 (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 (IsProxy)
return;
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)
{
// Compute the perfect aim direction: from the eye position toward the target
var perfectAimDirection = (ply.GameObject.WorldPosition + HeightToAimAt - EyePos).Normal;
// Smoothly interpolate between current and perfect aim direction
var newAimDirection = Vector3.Slerp(EyeAngles.Forward, perfectAimDirection, AimSpeed * Time.Delta);
// Now derive the horizontal yaw and vertical pitch from the new aim direction
// Calculate yaw from the x and y components
var newYaw = MathF.Atan2(newAimDirection.y, newAimDirection.x).RadianToDegree();
Rotate(newYaw);
// Calculate pitch from the z component and the horizontal length
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)
{
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)
=> 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)
=> MoveTo(Scene.NavMesh.GetSimplePathSafe(GameObject.WorldPosition, targetPosition));
/// <summary>
/// Navigate to the position given the path.
/// </summary>
/// <param name="path"></param>
public void MoveTo(List<Vector3> path)
{
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();
}
}