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
}