NPC refactor (#10122)
Co-authored-by: metalgearsloth <metalgearsloth@gmail.com>
This commit is contained in:
123
Content.Server/NPC/Systems/AiFactionTagSystem.cs
Normal file
123
Content.Server/NPC/Systems/AiFactionTagSystem.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using Content.Server.NPC.Components;
|
||||
using Robust.Shared.Prototypes;
|
||||
|
||||
namespace Content.Server.NPC.Systems
|
||||
{
|
||||
/// <summary>
|
||||
/// Outlines faction relationships with each other for AI.
|
||||
/// </summary>
|
||||
public sealed class AiFactionTagSystem : EntitySystem
|
||||
{
|
||||
/*
|
||||
* Currently factions are implicitly friendly if they are not hostile.
|
||||
* This may change where specified friendly factions are listed. (e.g. to get number of friendlies in area).
|
||||
*/
|
||||
|
||||
private readonly Dictionary<Faction, Faction> _hostileFactions = new();
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
var protoManager = IoCManager.Resolve<IPrototypeManager>();
|
||||
|
||||
foreach (var faction in protoManager.EnumeratePrototypes<AiFactionPrototype>())
|
||||
{
|
||||
if (Enum.TryParse(faction.ID, out Faction @enum))
|
||||
{
|
||||
var parsedFaction = Faction.None;
|
||||
|
||||
foreach (var hostile in faction.Hostile)
|
||||
{
|
||||
if (Enum.TryParse(hostile, out Faction parsedHostile))
|
||||
{
|
||||
parsedFaction |= parsedHostile;
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.Error($"Unable to parse hostile faction {hostile} for {faction.ID}");
|
||||
}
|
||||
}
|
||||
|
||||
_hostileFactions[@enum] = parsedFaction;
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.Error($"Unable to parse AI faction {faction.ID}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Faction GetHostileFactions(Faction faction) => _hostileFactions.TryGetValue(faction, out var hostiles) ? hostiles : Faction.None;
|
||||
|
||||
public Faction GetFactions(EntityUid entity) =>
|
||||
EntityManager.TryGetComponent(entity, out AiFactionTagComponent? factionTags)
|
||||
? factionTags.Factions
|
||||
: Faction.None;
|
||||
|
||||
public IEnumerable<EntityUid> GetNearbyHostiles(EntityUid entity, float range)
|
||||
{
|
||||
var ourFaction = GetFactions(entity);
|
||||
var hostile = GetHostileFactions(ourFaction);
|
||||
if (ourFaction == Faction.None || hostile == Faction.None)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
// TODO: Yes I know this system is shithouse
|
||||
var xformQuery = GetEntityQuery<TransformComponent>();
|
||||
var xform = xformQuery.GetComponent(entity);
|
||||
|
||||
foreach (var component in EntityManager.EntityQuery<AiFactionTagComponent>(true))
|
||||
{
|
||||
if ((component.Factions & hostile) == 0)
|
||||
continue;
|
||||
|
||||
if (!xformQuery.TryGetComponent(component.Owner, out var targetXform))
|
||||
continue;
|
||||
|
||||
if (targetXform.MapID != xform.MapID)
|
||||
continue;
|
||||
|
||||
if (!targetXform.Coordinates.InRange(EntityManager, xform.Coordinates, range))
|
||||
continue;
|
||||
|
||||
yield return component.Owner;
|
||||
}
|
||||
}
|
||||
|
||||
public void MakeFriendly(Faction source, Faction target)
|
||||
{
|
||||
if (!_hostileFactions.TryGetValue(source, out var hostileFactions))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
hostileFactions &= ~target;
|
||||
_hostileFactions[source] = hostileFactions;
|
||||
}
|
||||
|
||||
public void MakeHostile(Faction source, Faction target)
|
||||
{
|
||||
if (!_hostileFactions.TryGetValue(source, out var hostileFactions))
|
||||
{
|
||||
_hostileFactions[source] = target;
|
||||
return;
|
||||
}
|
||||
|
||||
hostileFactions |= target;
|
||||
_hostileFactions[source] = hostileFactions;
|
||||
}
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum Faction
|
||||
{
|
||||
None = 0,
|
||||
Dragon = 1 << 0,
|
||||
NanoTrasen = 1 << 1,
|
||||
SimpleHostile = 1 << 2,
|
||||
SimpleNeutral = 1 << 3,
|
||||
Syndicate = 1 << 4,
|
||||
Xeno = 1 << 5,
|
||||
}
|
||||
}
|
||||
89
Content.Server/NPC/Systems/NPCCombatSystem.Melee.cs
Normal file
89
Content.Server/NPC/Systems/NPCCombatSystem.Melee.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using Content.Server.CombatMode;
|
||||
using Content.Server.NPC.Components;
|
||||
using Content.Server.Weapon.Melee.Components;
|
||||
using Content.Shared.MobState;
|
||||
using Content.Shared.MobState.Components;
|
||||
|
||||
namespace Content.Server.NPC.Systems;
|
||||
|
||||
public sealed partial class NPCCombatSystem
|
||||
{
|
||||
private void InitializeMelee()
|
||||
{
|
||||
SubscribeLocalEvent<NPCMeleeCombatComponent, ComponentStartup>(OnMeleeStartup);
|
||||
SubscribeLocalEvent<NPCMeleeCombatComponent, ComponentShutdown>(OnMeleeShutdown);
|
||||
}
|
||||
|
||||
private void OnMeleeShutdown(EntityUid uid, NPCMeleeCombatComponent component, ComponentShutdown args)
|
||||
{
|
||||
if (TryComp<CombatModeComponent>(uid, out var combatMode))
|
||||
{
|
||||
combatMode.IsInCombatMode = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMeleeStartup(EntityUid uid, NPCMeleeCombatComponent component, ComponentStartup args)
|
||||
{
|
||||
if (TryComp<CombatModeComponent>(uid, out var combatMode))
|
||||
{
|
||||
combatMode.IsInCombatMode = true;
|
||||
}
|
||||
|
||||
// TODO: Cleanup later, just looking for parity for now.
|
||||
component.Weapon = uid;
|
||||
}
|
||||
|
||||
private void UpdateMelee(float frameTime)
|
||||
{
|
||||
var combatQuery = GetEntityQuery<CombatModeComponent>();
|
||||
var xformQuery = GetEntityQuery<TransformComponent>();
|
||||
|
||||
foreach (var (comp, _) in EntityQuery<NPCMeleeCombatComponent, ActiveNPCComponent>())
|
||||
{
|
||||
if (!combatQuery.TryGetComponent(comp.Owner, out var combat) || !combat.IsInCombatMode)
|
||||
{
|
||||
RemComp<NPCMeleeCombatComponent>(comp.Owner);
|
||||
continue;
|
||||
}
|
||||
|
||||
Attack(comp, xformQuery);
|
||||
}
|
||||
}
|
||||
|
||||
private void Attack(NPCMeleeCombatComponent component, EntityQuery<TransformComponent> xformQuery)
|
||||
{
|
||||
component.Status = CombatStatus.Normal;
|
||||
|
||||
// TODO: Also need to co-ordinate with steering to keep in range.
|
||||
// For now I've just moved the utlity version over.
|
||||
// Also need some blackboard data for stuff like juke frequency, assigning target slots (to surround targets), etc.
|
||||
// miss %
|
||||
if (!TryComp<MeleeWeaponComponent>(component.Weapon, out var weapon))
|
||||
{
|
||||
component.Status = CombatStatus.NoWeapon;
|
||||
return;
|
||||
}
|
||||
|
||||
if (weapon.CooldownEnd > _timing.CurTime)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!xformQuery.TryGetComponent(component.Owner, out var xform) ||
|
||||
!xformQuery.TryGetComponent(component.Target, out var targetXform))
|
||||
{
|
||||
component.Status = CombatStatus.TargetUnreachable;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!xform.Coordinates.TryDistance(EntityManager, targetXform.Coordinates, out var distance) ||
|
||||
distance > weapon.Range)
|
||||
{
|
||||
// TODO: Steering in combat.
|
||||
component.Status = CombatStatus.TargetUnreachable;
|
||||
return;
|
||||
}
|
||||
|
||||
_interaction.DoAttack(component.Owner, targetXform.Coordinates, false, component.Target);
|
||||
}
|
||||
}
|
||||
159
Content.Server/NPC/Systems/NPCCombatSystem.Ranged.cs
Normal file
159
Content.Server/NPC/Systems/NPCCombatSystem.Ranged.cs
Normal file
@@ -0,0 +1,159 @@
|
||||
using Content.Server.NPC.Components;
|
||||
using Content.Shared.CombatMode;
|
||||
using Content.Shared.Interaction;
|
||||
using Robust.Shared.Map;
|
||||
|
||||
namespace Content.Server.NPC.Systems;
|
||||
|
||||
public sealed partial class NPCCombatSystem
|
||||
{
|
||||
[Dependency] private readonly RotateToFaceSystem _rotate = default!;
|
||||
|
||||
// TODO: Don't predict for hitscan
|
||||
private const float ShootSpeed = 20f;
|
||||
|
||||
/// <summary>
|
||||
/// Cooldown on raycasting to check LOS.
|
||||
/// </summary>
|
||||
public const float UnoccludedCooldown = 0.2f;
|
||||
|
||||
private void InitializeRanged()
|
||||
{
|
||||
SubscribeLocalEvent<NPCRangedCombatComponent, ComponentStartup>(OnRangedStartup);
|
||||
SubscribeLocalEvent<NPCRangedCombatComponent, ComponentShutdown>(OnRangedShutdown);
|
||||
}
|
||||
|
||||
private void OnRangedStartup(EntityUid uid, NPCRangedCombatComponent component, ComponentStartup args)
|
||||
{
|
||||
if (TryComp<SharedCombatModeComponent>(uid, out var combat))
|
||||
{
|
||||
combat.IsInCombatMode = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
component.Status = CombatStatus.Unspecified;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRangedShutdown(EntityUid uid, NPCRangedCombatComponent component, ComponentShutdown args)
|
||||
{
|
||||
if (TryComp<SharedCombatModeComponent>(uid, out var combat))
|
||||
{
|
||||
combat.IsInCombatMode = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateRanged(float frameTime)
|
||||
{
|
||||
var bodyQuery = GetEntityQuery<PhysicsComponent>();
|
||||
var xformQuery = GetEntityQuery<TransformComponent>();
|
||||
var combatQuery = GetEntityQuery<SharedCombatModeComponent>();
|
||||
|
||||
foreach (var (comp, xform) in EntityQuery<NPCRangedCombatComponent, TransformComponent>())
|
||||
{
|
||||
if (comp.Status == CombatStatus.Unspecified)
|
||||
continue;
|
||||
|
||||
if (!xformQuery.TryGetComponent(comp.Target, out var targetXform) ||
|
||||
!bodyQuery.TryGetComponent(comp.Target, out var targetBody))
|
||||
{
|
||||
comp.Status = CombatStatus.TargetUnreachable;
|
||||
comp.ShootAccumulator = 0f;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (targetXform.MapID != xform.MapID)
|
||||
{
|
||||
comp.Status = CombatStatus.TargetUnreachable;
|
||||
comp.ShootAccumulator = 0f;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (combatQuery.TryGetComponent(comp.Owner, out var combatMode))
|
||||
{
|
||||
combatMode.IsInCombatMode = true;
|
||||
}
|
||||
|
||||
var gun = _gun.GetGun(comp.Owner);
|
||||
|
||||
if (gun == null)
|
||||
{
|
||||
comp.Status = CombatStatus.NoWeapon;
|
||||
comp.ShootAccumulator = 0f;
|
||||
continue;
|
||||
}
|
||||
|
||||
comp.LOSAccumulator -= frameTime;
|
||||
|
||||
var (worldPos, worldRot) = _transform.GetWorldPositionRotation(xform, xformQuery);
|
||||
var (targetPos, targetRot) = _transform.GetWorldPositionRotation(targetXform, xformQuery);
|
||||
|
||||
// We'll work out the projected spot of the target and shoot there instead of where they are.
|
||||
var distance = (targetPos - worldPos).Length;
|
||||
var oldInLos = comp.TargetInLOS;
|
||||
|
||||
// TODO: Should be doing these raycasts in parallel
|
||||
// Ideally we'd have 2 steps, 1. to go over the normal details for shooting and then 2. to handle beep / rotate / shoot
|
||||
if (comp.LOSAccumulator < 0f)
|
||||
{
|
||||
comp.LOSAccumulator += UnoccludedCooldown;
|
||||
comp.TargetInLOS = _interaction.InRangeUnobstructed(comp.Owner, comp.Target, distance + 0.1f);
|
||||
}
|
||||
|
||||
if (!comp.TargetInLOS)
|
||||
{
|
||||
comp.ShootAccumulator = 0f;
|
||||
comp.Status = CombatStatus.TargetUnreachable;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!oldInLos && comp.SoundTargetInLOS != null)
|
||||
{
|
||||
_audio.PlayPvs(comp.SoundTargetInLOS, comp.Owner);
|
||||
}
|
||||
|
||||
comp.ShootAccumulator += frameTime;
|
||||
|
||||
if (comp.ShootAccumulator < comp.ShootDelay)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var mapVelocity = targetBody.LinearVelocity;
|
||||
var targetSpot = targetPos + mapVelocity * distance / ShootSpeed;
|
||||
|
||||
// If we have a max rotation speed then do that.
|
||||
var goalRotation = (targetSpot - worldPos).ToWorldAngle();
|
||||
var rotationSpeed = comp.RotationSpeed;
|
||||
|
||||
if (!_rotate.TryRotateTo(comp.Owner, goalRotation, frameTime, comp.AccuracyThreshold, rotationSpeed?.Theta ?? double.MaxValue, xform))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: LOS
|
||||
// TODO: Ammo checks
|
||||
// TODO: Burst fire
|
||||
// TODO: Cycling
|
||||
// Max rotation speed
|
||||
|
||||
// TODO: Check if we can face
|
||||
|
||||
if (!_gun.CanShoot(gun))
|
||||
continue;
|
||||
|
||||
EntityCoordinates targetCordinates;
|
||||
|
||||
if (_mapManager.TryFindGridAt(xform.MapID, targetPos, out var mapGrid))
|
||||
{
|
||||
targetCordinates = new EntityCoordinates(mapGrid.GridEntityId, mapGrid.WorldToLocal(targetSpot));
|
||||
}
|
||||
else
|
||||
{
|
||||
targetCordinates = new EntityCoordinates(xform.MapUid!.Value, targetSpot);
|
||||
}
|
||||
|
||||
_gun.AttemptShoot(comp.Owner, gun, targetCordinates);
|
||||
}
|
||||
}
|
||||
}
|
||||
35
Content.Server/NPC/Systems/NPCCombatSystem.cs
Normal file
35
Content.Server/NPC/Systems/NPCCombatSystem.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using Content.Server.Interaction;
|
||||
using Content.Server.Weapon.Ranged.Systems;
|
||||
using Content.Shared.CombatMode;
|
||||
using Content.Shared.Interaction;
|
||||
using Robust.Shared.Map;
|
||||
using Robust.Shared.Timing;
|
||||
|
||||
namespace Content.Server.NPC.Systems;
|
||||
|
||||
/// <summary>
|
||||
/// Handles combat for NPCs.
|
||||
/// </summary>
|
||||
public sealed partial class NPCCombatSystem : EntitySystem
|
||||
{
|
||||
[Dependency] private readonly IGameTiming _timing = default!;
|
||||
[Dependency] private readonly IMapManager _mapManager = default!;
|
||||
[Dependency] private readonly GunSystem _gun = default!;
|
||||
[Dependency] private readonly InteractionSystem _interaction = default!;
|
||||
[Dependency] private readonly SharedAudioSystem _audio = default!;
|
||||
[Dependency] private readonly SharedTransformSystem _transform = default!;
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
InitializeMelee();
|
||||
InitializeRanged();
|
||||
}
|
||||
|
||||
public override void Update(float frameTime)
|
||||
{
|
||||
base.Update(frameTime);
|
||||
UpdateMelee(frameTime);
|
||||
UpdateRanged(frameTime);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Content.Server.NPC.Components;
|
||||
|
||||
namespace Content.Server.NPC.Systems;
|
||||
|
||||
public sealed partial class NPCPerceptionSystem
|
||||
{
|
||||
/// <summary>
|
||||
/// Tracks targets recently injected by medibots.
|
||||
/// </summary>
|
||||
/// <param name="frameTime"></param>
|
||||
private void UpdateRecentlyInjected(float frameTime)
|
||||
{
|
||||
foreach (var entity in EntityQuery<NPCRecentlyInjectedComponent>())
|
||||
{
|
||||
entity.Accumulator += frameTime;
|
||||
if (entity.Accumulator < entity.RemoveTime.TotalSeconds)
|
||||
continue;
|
||||
entity.Accumulator = 0;
|
||||
|
||||
RemComp<NPCRecentlyInjectedComponent>(entity.Owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
13
Content.Server/NPC/Systems/NPCPerceptionSystem.cs
Normal file
13
Content.Server/NPC/Systems/NPCPerceptionSystem.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Content.Server.NPC.Systems;
|
||||
|
||||
/// <summary>
|
||||
/// Handles sight + sounds for NPCs.
|
||||
/// </summary>
|
||||
public sealed partial class NPCPerceptionSystem : EntitySystem
|
||||
{
|
||||
public override void Update(float frameTime)
|
||||
{
|
||||
base.Update(frameTime);
|
||||
UpdateRecentlyInjected(frameTime);
|
||||
}
|
||||
}
|
||||
44
Content.Server/NPC/Systems/NPCSteeringSystem.Avoidance.cs
Normal file
44
Content.Server/NPC/Systems/NPCSteeringSystem.Avoidance.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System.Linq;
|
||||
using Content.Server.NPC.Components;
|
||||
using Content.Shared.CCVar;
|
||||
using Content.Shared.Movement.Components;
|
||||
using Robust.Shared.Collections;
|
||||
using Robust.Shared.Configuration;
|
||||
using Robust.Shared.Physics;
|
||||
using Robust.Shared.Physics.Collision.Shapes;
|
||||
|
||||
namespace Content.Server.NPC.Systems;
|
||||
|
||||
public sealed partial class NPCSteeringSystem
|
||||
{
|
||||
// TODO
|
||||
|
||||
// Derived from RVO2 library which uses ORCA (optimal reciprocal collision avoidance).
|
||||
// Could also potentially use something force based or RVO or detour crowd.
|
||||
|
||||
public bool CollisionAvoidanceEnabled { get; set; } = true;
|
||||
|
||||
public bool ObstacleAvoidanceEnabled { get; set; } = true;
|
||||
|
||||
private const float Radius = 0.35f;
|
||||
private const float RVO_EPSILON = 0.00001f;
|
||||
|
||||
private void InitializeAvoidance()
|
||||
{
|
||||
var configManager = IoCManager.Resolve<IConfigurationManager>();
|
||||
configManager.OnValueChanged(CCVars.NPCCollisionAvoidance, SetCollisionAvoidance);
|
||||
}
|
||||
|
||||
private void ShutdownAvoidance()
|
||||
{
|
||||
var configManager = IoCManager.Resolve<IConfigurationManager>();
|
||||
configManager.UnsubValueChanged(CCVars.NPCCollisionAvoidance, SetCollisionAvoidance);
|
||||
}
|
||||
|
||||
// I deleted all of my relevant code for now as I only had dynamic body avoidance working and not static
|
||||
// but it will be added back real soon.
|
||||
private void SetCollisionAvoidance(bool obj)
|
||||
{
|
||||
CollisionAvoidanceEnabled = obj;
|
||||
}
|
||||
}
|
||||
412
Content.Server/NPC/Systems/NPCSteeringSystem.cs
Normal file
412
Content.Server/NPC/Systems/NPCSteeringSystem.cs
Normal file
@@ -0,0 +1,412 @@
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Content.Server.CPUJob.JobQueues;
|
||||
using Content.Server.NPC.Components;
|
||||
using Content.Server.NPC.Pathfinding;
|
||||
using Content.Server.NPC.Pathfinding.Pathfinders;
|
||||
using Content.Shared.Access.Systems;
|
||||
using Content.Shared.CCVar;
|
||||
using Content.Shared.Movement.Components;
|
||||
using Robust.Shared.Configuration;
|
||||
using Robust.Shared.Map;
|
||||
using Robust.Shared.Timing;
|
||||
|
||||
namespace Content.Server.NPC.Systems
|
||||
{
|
||||
public sealed partial class NPCSteeringSystem : EntitySystem
|
||||
{
|
||||
// http://www.red3d.com/cwr/papers/1999/gdc99steer.html for a steering overview
|
||||
[Dependency] private readonly IConfigurationManager _configManager = default!;
|
||||
[Dependency] private readonly IGameTiming _timing = default!;
|
||||
[Dependency] private readonly IMapManager _mapManager = default!;
|
||||
[Dependency] private readonly AccessReaderSystem _accessReader = default!;
|
||||
[Dependency] private readonly PathfindingSystem _pathfindingSystem = default!;
|
||||
[Dependency] private readonly SharedPhysicsSystem _physics = default!;
|
||||
|
||||
// This will likely get moved onto an abstract pathfinding node that specifies the max distance allowed from the coordinate.
|
||||
private const float TileTolerance = 0.4f;
|
||||
|
||||
private bool _enabled;
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
InitializeAvoidance();
|
||||
_configManager.OnValueChanged(CCVars.NPCEnabled, SetNPCEnabled, true);
|
||||
|
||||
SubscribeLocalEvent<NPCSteeringComponent, ComponentShutdown>(OnSteeringShutdown);
|
||||
}
|
||||
|
||||
private void OnSteeringShutdown(EntityUid uid, NPCSteeringComponent component, ComponentShutdown args)
|
||||
{
|
||||
component.PathfindToken?.Cancel();
|
||||
}
|
||||
|
||||
private void SetNPCEnabled(bool obj)
|
||||
{
|
||||
if (!obj)
|
||||
{
|
||||
foreach (var (_, mover) in EntityQuery<NPCSteeringComponent, InputMoverComponent>())
|
||||
{
|
||||
mover.CurTickSprintMovement = Vector2.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
_enabled = obj;
|
||||
}
|
||||
|
||||
public override void Shutdown()
|
||||
{
|
||||
base.Shutdown();
|
||||
ShutdownAvoidance();
|
||||
_configManager.UnsubValueChanged(CCVars.NPCEnabled, SetNPCEnabled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the AI to the steering system to move towards a specific target
|
||||
/// </summary>
|
||||
public NPCSteeringComponent Register(EntityUid uid, EntityCoordinates coordinates)
|
||||
{
|
||||
if (TryComp<NPCSteeringComponent>(uid, out var comp))
|
||||
{
|
||||
comp.PathfindToken?.Cancel();
|
||||
comp.PathfindToken = null;
|
||||
comp.CurrentPath.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
comp = AddComp<NPCSteeringComponent>(uid);
|
||||
}
|
||||
|
||||
EnsureComp<NPCRVOComponent>(uid);
|
||||
comp.Coordinates = coordinates;
|
||||
return comp;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the steering behavior for the AI and cleans up.
|
||||
/// </summary>
|
||||
public void Unregister(EntityUid uid, NPCSteeringComponent? component = null)
|
||||
{
|
||||
if (!Resolve(uid, ref component, false))
|
||||
return;
|
||||
|
||||
if (EntityManager.TryGetComponent(component.Owner, out InputMoverComponent? controller))
|
||||
{
|
||||
controller.CurTickSprintMovement = Vector2.Zero;
|
||||
}
|
||||
|
||||
component.PathfindToken?.Cancel();
|
||||
component.PathfindToken = null;
|
||||
component.Pathfind = null;
|
||||
RemComp<NPCRVOComponent>(uid);
|
||||
RemComp<NPCSteeringComponent>(uid);
|
||||
}
|
||||
|
||||
public override void Update(float frameTime)
|
||||
{
|
||||
base.Update(frameTime);
|
||||
|
||||
if (!_enabled)
|
||||
return;
|
||||
|
||||
// Not every mob has the modifier component so do it as a separate query.
|
||||
var bodyQuery = GetEntityQuery<PhysicsComponent>();
|
||||
var modifierQuery = GetEntityQuery<MovementSpeedModifierComponent>();
|
||||
|
||||
var npcs = EntityQuery<NPCSteeringComponent, ActiveNPCComponent, InputMoverComponent, TransformComponent>()
|
||||
.ToArray();
|
||||
|
||||
// TODO: Do this in parallel. This will require pathfinder refactor to not use jobqueue.
|
||||
foreach (var (steering, _, mover, xform) in npcs)
|
||||
{
|
||||
Steer(steering, mover, xform, modifierQuery, bodyQuery, frameTime);
|
||||
}
|
||||
}
|
||||
|
||||
private void SetDirection(InputMoverComponent component, Vector2 value)
|
||||
{
|
||||
component.CurTickSprintMovement = value;
|
||||
component.LastInputTick = _timing.CurTick;
|
||||
component.LastInputSubTick = ushort.MaxValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Go through each steerer and combine their vectors
|
||||
/// </summary>
|
||||
private void Steer(
|
||||
NPCSteeringComponent steering,
|
||||
InputMoverComponent mover,
|
||||
TransformComponent xform,
|
||||
EntityQuery<MovementSpeedModifierComponent> modifierQuery,
|
||||
EntityQuery<PhysicsComponent> bodyQuery,
|
||||
float frameTime)
|
||||
{
|
||||
var ourCoordinates = xform.Coordinates;
|
||||
var destinationCoordinates = steering.Coordinates;
|
||||
|
||||
// We've arrived, nothing else matters.
|
||||
if (xform.Coordinates.TryDistance(EntityManager, destinationCoordinates, out var distance) &&
|
||||
distance <= steering.Range)
|
||||
{
|
||||
SetDirection(mover, Vector2.Zero);
|
||||
steering.Status = SteeringStatus.InRange;
|
||||
return;
|
||||
}
|
||||
|
||||
// Can't move at all, just noop input.
|
||||
if (!mover.CanMove)
|
||||
{
|
||||
SetDirection(mover, Vector2.Zero);
|
||||
steering.Status = SteeringStatus.Moving;
|
||||
return;
|
||||
}
|
||||
|
||||
// If we were pathfinding then try to update our path.
|
||||
if (steering.Pathfind != null)
|
||||
{
|
||||
switch (steering.Pathfind.Status)
|
||||
{
|
||||
case JobStatus.Waiting:
|
||||
case JobStatus.Running:
|
||||
case JobStatus.Pending:
|
||||
case JobStatus.Paused:
|
||||
break;
|
||||
case JobStatus.Finished:
|
||||
steering.CurrentPath.Clear();
|
||||
|
||||
if (steering.Pathfind.Result != null)
|
||||
{
|
||||
PrunePath(ourCoordinates, steering.Pathfind.Result);
|
||||
|
||||
foreach (var node in steering.Pathfind.Result)
|
||||
{
|
||||
steering.CurrentPath.Enqueue(node);
|
||||
}
|
||||
}
|
||||
|
||||
steering.Pathfind = null;
|
||||
steering.PathfindToken = null;
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
}
|
||||
|
||||
// Grab the target position, either the path or our end goal.
|
||||
// TODO: Some situations we may not want to move at our target without a path.
|
||||
var targetCoordinates = GetTargetCoordinates(steering);
|
||||
var arrivalDistance = TileTolerance;
|
||||
|
||||
if (targetCoordinates.Equals(steering.Coordinates))
|
||||
{
|
||||
// What's our tolerance for arrival.
|
||||
// If it's a pathfinding node it might be different to the destination.
|
||||
arrivalDistance = steering.Range;
|
||||
}
|
||||
|
||||
// Check if mapids match.
|
||||
var targetMap = targetCoordinates.ToMap(EntityManager);
|
||||
var ourMap = ourCoordinates.ToMap(EntityManager);
|
||||
|
||||
if (targetMap.MapId != ourMap.MapId)
|
||||
{
|
||||
SetDirection(mover, Vector2.Zero);
|
||||
steering.Status = SteeringStatus.NoPath;
|
||||
steering.CurrentTarget = targetCoordinates;
|
||||
return;
|
||||
}
|
||||
|
||||
var direction = targetMap.Position - ourMap.Position;
|
||||
|
||||
// Are we in range
|
||||
if (direction.Length <= arrivalDistance)
|
||||
{
|
||||
// It was just a node, not the target, so grab the next destination (either the target or next node).
|
||||
if (steering.CurrentPath.Count > 0)
|
||||
{
|
||||
steering.CurrentPath.Dequeue();
|
||||
|
||||
// Alright just adjust slightly and grab the next node so we don't stop moving for a tick.
|
||||
// TODO: If it's the last node just grab the target instead.
|
||||
targetCoordinates = GetTargetCoordinates(steering);
|
||||
targetMap = targetCoordinates.ToMap(EntityManager);
|
||||
|
||||
// Can't make it again.
|
||||
if (ourMap.MapId != targetMap.MapId)
|
||||
{
|
||||
SetDirection(mover, Vector2.Zero);
|
||||
steering.Status = SteeringStatus.NoPath;
|
||||
steering.CurrentTarget = targetCoordinates;
|
||||
return;
|
||||
}
|
||||
|
||||
// Gonna resume now business as usual
|
||||
direction = targetMap.Position - ourMap.Position;
|
||||
}
|
||||
else
|
||||
{
|
||||
// This probably shouldn't happen as we check above but eh.
|
||||
SetDirection(mover, Vector2.Zero);
|
||||
steering.Status = SteeringStatus.InRange;
|
||||
steering.CurrentTarget = targetCoordinates;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Do we have no more nodes to follow OR has the target moved sufficiently? If so then re-path.
|
||||
var needsPath = steering.CurrentPath.Count == 0;
|
||||
|
||||
// TODO: Probably need partial planning support i.e. patch from the last node to where the target moved to.
|
||||
|
||||
if (!needsPath)
|
||||
{
|
||||
var lastNode = steering.CurrentPath.Last();
|
||||
// I know this is bad and doesn't account for tile size
|
||||
// However with the path I'm going to change it to return pathfinding nodes which include coordinates instead.
|
||||
var lastCoordinate = new EntityCoordinates(lastNode.GridUid, (Vector2) lastNode.GridIndices + 0.5f);
|
||||
|
||||
if (lastCoordinate.TryDistance(EntityManager, steering.Coordinates, out var lastDistance) &&
|
||||
lastDistance > steering.RepathRange)
|
||||
{
|
||||
needsPath = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Request the new path.
|
||||
if (needsPath && bodyQuery.TryGetComponent(steering.Owner, out var body))
|
||||
{
|
||||
RequestPath(steering, xform, body);
|
||||
}
|
||||
|
||||
modifierQuery.TryGetComponent(steering.Owner, out var modifier);
|
||||
var moveSpeed = GetSprintSpeed(steering.Owner, modifier);
|
||||
|
||||
var input = direction.Normalized;
|
||||
|
||||
// If we're going to overshoot then... don't.
|
||||
// TODO: For tile / movement we don't need to get bang on, just need to make sure we don't overshoot the far end.
|
||||
var tickMovement = moveSpeed * frameTime;
|
||||
|
||||
if (tickMovement.Equals(0f))
|
||||
{
|
||||
SetDirection(mover, Vector2.Zero);
|
||||
steering.Status = SteeringStatus.NoPath;
|
||||
steering.CurrentTarget = targetCoordinates;
|
||||
return;
|
||||
}
|
||||
|
||||
// We may overshoot slightly but still be in the arrival distance which is okay.
|
||||
var maxDistance = direction.Length + arrivalDistance;
|
||||
|
||||
if (tickMovement > maxDistance)
|
||||
{
|
||||
input *= maxDistance / tickMovement;
|
||||
}
|
||||
|
||||
// TODO: This isn't going to work for space.
|
||||
if (_mapManager.TryGetGrid(xform.GridUid, out var grid))
|
||||
{
|
||||
input = (-grid.WorldRotation).RotateVec(input);
|
||||
}
|
||||
|
||||
SetDirection(mover, input);
|
||||
steering.CurrentTarget = targetCoordinates;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// We may be pathfinding and moving at the same time in which case early nodes may be out of date.
|
||||
/// </summary>
|
||||
/// <param name="coordinates">Our coordinates we are pruning from</param>
|
||||
/// <param name="nodes">Path we're pruning</param>
|
||||
public void PrunePath(EntityCoordinates coordinates, Queue<TileRef> nodes)
|
||||
{
|
||||
if (nodes.Count == 0)
|
||||
return;
|
||||
|
||||
// Right now the pathfinder gives EVERY TILE back but ideally it won't someday, it'll just give straightline ones.
|
||||
// For now, we just prune up until the closest node + 1 extra.
|
||||
var closest = ((Vector2) nodes.Peek().GridIndices + 0.5f - coordinates.Position).Length;
|
||||
// TODO: Need to handle multi-grid and stuff.
|
||||
|
||||
while (nodes.TryPeek(out var node))
|
||||
{
|
||||
// TODO: Tile size
|
||||
var nodePosition = (Vector2) node.GridIndices + 0.5f;
|
||||
var length = (coordinates.Position - nodePosition).Length;
|
||||
|
||||
if (length < closest)
|
||||
{
|
||||
closest = length;
|
||||
nodes.Dequeue();
|
||||
continue;
|
||||
}
|
||||
|
||||
nodes.Dequeue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the coordinates we should be heading towards.
|
||||
/// </summary>
|
||||
private EntityCoordinates GetTargetCoordinates(NPCSteeringComponent steering)
|
||||
{
|
||||
// Depending on what's going on we may return the target or a pathfind node.
|
||||
|
||||
// Even if we're at the last node may not be able to head to target in case we get stuck on a corner or the likes.
|
||||
if (steering.CurrentPath.Count >= 1 && steering.CurrentPath.TryPeek(out var nextTarget))
|
||||
{
|
||||
return new EntityCoordinates(nextTarget.GridUid, (Vector2) nextTarget.GridIndices + 0.5f);
|
||||
}
|
||||
|
||||
return steering.Coordinates;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a new job from the pathfindingsystem
|
||||
/// </summary>
|
||||
private void RequestPath(NPCSteeringComponent steering, TransformComponent xform, PhysicsComponent? body)
|
||||
{
|
||||
// If we already have a pathfinding request then don't grab another.
|
||||
if (steering.Pathfind != null)
|
||||
return;
|
||||
|
||||
if (!_mapManager.TryGetGrid(xform.GridUid, out var grid))
|
||||
return;
|
||||
|
||||
steering.PathfindToken = new CancellationTokenSource();
|
||||
var startTile = grid.GetTileRef(xform.Coordinates);
|
||||
var endTile = grid.GetTileRef(steering.Coordinates);
|
||||
var collisionMask = 0;
|
||||
|
||||
if (body != null)
|
||||
{
|
||||
collisionMask = body.CollisionMask;
|
||||
}
|
||||
|
||||
var access = _accessReader.FindAccessTags(steering.Owner);
|
||||
|
||||
steering.Pathfind = _pathfindingSystem.RequestPath(new PathfindingArgs(
|
||||
steering.Owner,
|
||||
access,
|
||||
collisionMask,
|
||||
startTile,
|
||||
endTile,
|
||||
steering.Range
|
||||
), steering.PathfindToken.Token);
|
||||
}
|
||||
|
||||
// TODO: Move these to movercontroller
|
||||
|
||||
private float GetSprintSpeed(EntityUid uid, MovementSpeedModifierComponent? modifier = null)
|
||||
{
|
||||
if (!Resolve(uid, ref modifier, false))
|
||||
{
|
||||
return MovementSpeedModifierComponent.DefaultBaseSprintSpeed;
|
||||
}
|
||||
|
||||
return modifier.CurrentSprintSpeed;
|
||||
}
|
||||
}
|
||||
}
|
||||
17
Content.Server/NPC/Systems/NPCSystem.Blackboard.cs
Normal file
17
Content.Server/NPC/Systems/NPCSystem.Blackboard.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using Content.Server.NPC.Components;
|
||||
|
||||
namespace Content.Server.NPC.Systems;
|
||||
|
||||
public sealed partial class NPCSystem
|
||||
{
|
||||
public void SetBlackboard(EntityUid uid, string key, object value, NPCComponent? component = null)
|
||||
{
|
||||
if (!Resolve(uid, ref component, false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var blackboard = component.Blackboard;
|
||||
blackboard.SetValue(key, value);
|
||||
}
|
||||
}
|
||||
9
Content.Server/NPC/Systems/NPCSystem.Debug.cs
Normal file
9
Content.Server/NPC/Systems/NPCSystem.Debug.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Content.Server.NPC.Systems;
|
||||
|
||||
public sealed partial class NPCSystem
|
||||
{
|
||||
private void InitializeDebug()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
131
Content.Server/NPC/Systems/NPCSystem.cs
Normal file
131
Content.Server/NPC/Systems/NPCSystem.cs
Normal file
@@ -0,0 +1,131 @@
|
||||
using Content.Server.NPC.Components;
|
||||
using Content.Server.NPC.HTN;
|
||||
using Content.Shared.CCVar;
|
||||
using Content.Shared.MobState;
|
||||
using JetBrains.Annotations;
|
||||
using Robust.Shared.Configuration;
|
||||
using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Reflection;
|
||||
|
||||
namespace Content.Server.NPC.Systems
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles NPCs running every tick.
|
||||
/// </summary>
|
||||
[UsedImplicitly]
|
||||
public sealed partial class NPCSystem : EntitySystem
|
||||
{
|
||||
[Dependency] private readonly IConfigurationManager _configurationManager = default!;
|
||||
[Dependency] private readonly HTNSystem _htn = default!;
|
||||
|
||||
private ISawmill _sawmill = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Whether any NPCs are allowed to run at all.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
private int _maxUpdates;
|
||||
|
||||
private int _count;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
// Makes physics etc debugging easier.
|
||||
#if DEBUG
|
||||
_configurationManager.OverrideDefault(CCVars.NPCEnabled, false);
|
||||
#endif
|
||||
|
||||
_sawmill = Logger.GetSawmill("npc");
|
||||
_sawmill.Level = LogLevel.Info;
|
||||
SubscribeLocalEvent<NPCComponent, MobStateChangedEvent>(OnMobStateChange);
|
||||
SubscribeLocalEvent<NPCComponent, MapInitEvent>(OnNPCMapInit);
|
||||
SubscribeLocalEvent<NPCComponent, ComponentShutdown>(OnNPCShutdown);
|
||||
_configurationManager.OnValueChanged(CCVars.NPCEnabled, SetEnabled, true);
|
||||
_configurationManager.OnValueChanged(CCVars.NPCMaxUpdates, SetMaxUpdates, true);
|
||||
}
|
||||
|
||||
private void SetMaxUpdates(int obj) => _maxUpdates = obj;
|
||||
private void SetEnabled(bool value) => Enabled = value;
|
||||
|
||||
public override void Shutdown()
|
||||
{
|
||||
base.Shutdown();
|
||||
_configurationManager.UnsubValueChanged(CCVars.NPCEnabled, SetEnabled);
|
||||
_configurationManager.UnsubValueChanged(CCVars.NPCMaxUpdates, SetMaxUpdates);
|
||||
}
|
||||
|
||||
private void OnNPCMapInit(EntityUid uid, NPCComponent component, MapInitEvent args)
|
||||
{
|
||||
component.Blackboard.SetValue(NPCBlackboard.Owner, uid);
|
||||
WakeNPC(uid, component);
|
||||
}
|
||||
|
||||
private void OnNPCShutdown(EntityUid uid, NPCComponent component, ComponentShutdown args)
|
||||
{
|
||||
SleepNPC(uid, component);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Is the NPC awake and updating?
|
||||
/// </summary>
|
||||
public bool IsAwake(NPCComponent component, ActiveNPCComponent? active = null)
|
||||
{
|
||||
return Resolve(component.Owner, ref active, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allows the NPC to actively be updated.
|
||||
/// </summary>
|
||||
public void WakeNPC(EntityUid uid, NPCComponent? component = null)
|
||||
{
|
||||
if (!Resolve(uid, ref component, false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_sawmill.Debug($"Waking {ToPrettyString(component.Owner)}");
|
||||
EnsureComp<ActiveNPCComponent>(component.Owner);
|
||||
}
|
||||
|
||||
public void SleepNPC(EntityUid uid, NPCComponent? component = null)
|
||||
{
|
||||
if (!Resolve(uid, ref component, false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_sawmill.Debug($"Sleeping {ToPrettyString(component.Owner)}");
|
||||
RemComp<ActiveNPCComponent>(component.Owner);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Update(float frameTime)
|
||||
{
|
||||
base.Update(frameTime);
|
||||
|
||||
if (!Enabled)
|
||||
return;
|
||||
|
||||
_count = 0;
|
||||
// Add your system here.
|
||||
_htn.UpdateNPC(ref _count, _maxUpdates, frameTime);
|
||||
}
|
||||
|
||||
private void OnMobStateChange(EntityUid uid, NPCComponent component, MobStateChangedEvent args)
|
||||
{
|
||||
switch (args.CurrentMobState)
|
||||
{
|
||||
case DamageState.Alive:
|
||||
WakeNPC(uid, component);
|
||||
break;
|
||||
case DamageState.Critical:
|
||||
case DamageState.Dead:
|
||||
SleepNPC(uid, component);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user