Context steering for NPCs (#12915)
This commit is contained in:
@@ -1,31 +1,54 @@
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Content.Server.Administration.Managers;
|
||||
using Content.Server.Doors.Systems;
|
||||
using Content.Server.NPC.Components;
|
||||
using Content.Server.NPC.Events;
|
||||
using Content.Server.NPC.Pathfinding;
|
||||
using Content.Shared.CCVar;
|
||||
using Content.Shared.Interaction;
|
||||
using Content.Shared.Movement.Components;
|
||||
using Content.Shared.Movement.Systems;
|
||||
using Content.Shared.NPC;
|
||||
using Content.Shared.NPC.Events;
|
||||
using Content.Shared.Weapons.Melee;
|
||||
using Robust.Server.Player;
|
||||
using Robust.Shared.Configuration;
|
||||
using Robust.Shared.Map;
|
||||
using Robust.Shared.Physics.Components;
|
||||
using Robust.Shared.Physics.Systems;
|
||||
using Robust.Shared.Player;
|
||||
using Robust.Shared.Players;
|
||||
using Robust.Shared.Random;
|
||||
using Robust.Shared.Threading;
|
||||
using Robust.Shared.Timing;
|
||||
using Robust.Shared.Utility;
|
||||
|
||||
namespace Content.Server.NPC.Systems
|
||||
{
|
||||
public sealed partial class NPCSteeringSystem : EntitySystem
|
||||
public sealed partial class NPCSteeringSystem : SharedNPCSteeringSystem
|
||||
{
|
||||
// http://www.red3d.com/cwr/papers/1999/gdc99steer.html for a steering overview
|
||||
/*
|
||||
* We use context steering to determine which way to move.
|
||||
* This involves creating an array of possible directions and assigning a value for the desireability of each direction.
|
||||
*
|
||||
* There's multiple ways to implement this, e.g. you can average all directions, or you can choose the highest direction
|
||||
* , or you can remove the danger map entirely and only having an interest map (AKA game endeavour).
|
||||
* See http://www.gameaipro.com/GameAIPro2/GameAIPro2_Chapter18_Context_Steering_Behavior-Driven_Steering_at_the_Macro_Scale.pdf
|
||||
* (though in their case it was for an F1 game so used context steering across the width of the road).
|
||||
*/
|
||||
|
||||
[Dependency] private readonly IAdminManager _admin = default!;
|
||||
[Dependency] private readonly IConfigurationManager _configManager = default!;
|
||||
[Dependency] private readonly IGameTiming _timing = default!;
|
||||
[Dependency] private readonly IMapManager _mapManager = default!;
|
||||
[Dependency] private readonly IParallelManager _parallel = default!;
|
||||
[Dependency] private readonly IRobustRandom _random = default!;
|
||||
[Dependency] private readonly DoorSystem _doors = default!;
|
||||
[Dependency] private readonly EntityLookupSystem _lookup = default!;
|
||||
[Dependency] private readonly FactionSystem _faction = default!;
|
||||
// [Dependency] private readonly MetaDataSystem _metadata = default!;
|
||||
[Dependency] private readonly PathfindingSystem _pathfindingSystem = default!;
|
||||
[Dependency] private readonly SharedInteractionSystem _interaction = default!;
|
||||
[Dependency] private readonly SharedMeleeWeaponSystem _melee = default!;
|
||||
@@ -37,19 +60,28 @@ namespace Content.Server.NPC.Systems
|
||||
|
||||
private bool _enabled;
|
||||
|
||||
private bool _pathfinding = true;
|
||||
|
||||
public static readonly Vector2[] Directions = new Vector2[InterestDirections];
|
||||
|
||||
private readonly HashSet<ICommonSession> _subscribedSessions = new();
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
|
||||
for (var i = 0; i < InterestDirections; i++)
|
||||
{
|
||||
Directions[i] = new Angle(InterestRadians * i).ToVec();
|
||||
}
|
||||
|
||||
UpdatesBefore.Add(typeof(SharedPhysicsSystem));
|
||||
InitializeAvoidance();
|
||||
_configManager.OnValueChanged(CCVars.NPCEnabled, SetNPCEnabled, true);
|
||||
_configManager.OnValueChanged(CCVars.NPCEnabled, SetNPCEnabled);
|
||||
_configManager.OnValueChanged(CCVars.NPCPathfinding, SetNPCPathfinding);
|
||||
|
||||
SubscribeLocalEvent<NPCSteeringComponent, ComponentShutdown>(OnSteeringShutdown);
|
||||
}
|
||||
|
||||
private void OnSteeringShutdown(EntityUid uid, NPCSteeringComponent component, ComponentShutdown args)
|
||||
{
|
||||
component.PathfindToken?.Cancel();
|
||||
SubscribeNetworkEvent<RequestNPCSteeringDebugEvent>(OnDebugRequest);
|
||||
}
|
||||
|
||||
private void SetNPCEnabled(bool obj)
|
||||
@@ -65,6 +97,20 @@ namespace Content.Server.NPC.Systems
|
||||
_enabled = obj;
|
||||
}
|
||||
|
||||
private void SetNPCPathfinding(bool value)
|
||||
{
|
||||
_pathfinding = value;
|
||||
|
||||
if (!_pathfinding)
|
||||
{
|
||||
foreach (var comp in EntityQuery<NPCSteeringComponent>(true))
|
||||
{
|
||||
comp.PathfindToken?.Cancel();
|
||||
comp.PathfindToken = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override void Shutdown()
|
||||
{
|
||||
base.Shutdown();
|
||||
@@ -72,6 +118,22 @@ namespace Content.Server.NPC.Systems
|
||||
_configManager.UnsubValueChanged(CCVars.NPCEnabled, SetNPCEnabled);
|
||||
}
|
||||
|
||||
private void OnDebugRequest(RequestNPCSteeringDebugEvent msg, EntitySessionEventArgs args)
|
||||
{
|
||||
if (!_admin.IsAdmin((IPlayerSession) args.SenderSession))
|
||||
return;
|
||||
|
||||
if (msg.Enabled)
|
||||
_subscribedSessions.Add(args.SenderSession);
|
||||
else
|
||||
_subscribedSessions.Remove(args.SenderSession);
|
||||
}
|
||||
|
||||
private void OnSteeringShutdown(EntityUid uid, NPCSteeringComponent component, ComponentShutdown args)
|
||||
{
|
||||
component.PathfindToken?.Cancel();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the AI to the steering system to move towards a specific target
|
||||
/// </summary>
|
||||
@@ -137,15 +199,41 @@ namespace Content.Server.NPC.Systems
|
||||
// Not every mob has the modifier component so do it as a separate query.
|
||||
var bodyQuery = GetEntityQuery<PhysicsComponent>();
|
||||
var modifierQuery = GetEntityQuery<MovementSpeedModifierComponent>();
|
||||
var xformQuery = GetEntityQuery<TransformComponent>();
|
||||
|
||||
var npcs = EntityQuery<NPCSteeringComponent, ActiveNPCComponent, InputMoverComponent, TransformComponent>()
|
||||
.ToArray();
|
||||
|
||||
// TODO: Do this in parallel.
|
||||
// Main obstacle is requesting a new path needs to be done synchronously
|
||||
foreach (var (steering, _, mover, xform) in npcs)
|
||||
var options = new ParallelOptions()
|
||||
{
|
||||
Steer(steering, mover, xform, modifierQuery, bodyQuery, frameTime);
|
||||
MaxDegreeOfParallelism = _parallel.ParallelProcessCount,
|
||||
};
|
||||
|
||||
Parallel.For(0, npcs.Length, options, i =>
|
||||
{
|
||||
var (steering, _, mover, xform) = npcs[i];
|
||||
|
||||
Steer(steering, mover, xform, modifierQuery, bodyQuery, xformQuery, frameTime);
|
||||
steering.LastSteer = mover.CurTickSprintMovement;
|
||||
});
|
||||
|
||||
if (_subscribedSessions.Count > 0)
|
||||
{
|
||||
var data = new List<NPCSteeringDebugData>(npcs.Length);
|
||||
|
||||
foreach (var (steering, _, mover, _) in npcs)
|
||||
{
|
||||
data.Add(new NPCSteeringDebugData(
|
||||
mover.Owner,
|
||||
mover.CurTickSprintMovement,
|
||||
steering.Interest,
|
||||
steering.Danger,
|
||||
steering.DangerPoints));
|
||||
}
|
||||
|
||||
var filter = Filter.Empty();
|
||||
filter.AddPlayers(_subscribedSessions);
|
||||
|
||||
RaiseNetworkEvent(new NPCSteeringDebugEvent(data), filter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +258,7 @@ namespace Content.Server.NPC.Systems
|
||||
TransformComponent xform,
|
||||
EntityQuery<MovementSpeedModifierComponent> modifierQuery,
|
||||
EntityQuery<PhysicsComponent> bodyQuery,
|
||||
EntityQuery<TransformComponent> xformQuery,
|
||||
float frameTime)
|
||||
{
|
||||
if (Deleted(steering.Coordinates.EntityId))
|
||||
@@ -179,242 +268,93 @@ namespace Content.Server.NPC.Systems
|
||||
return;
|
||||
}
|
||||
|
||||
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)
|
||||
// No path set from pathfinding or the likes.
|
||||
if (steering.Status == SteeringStatus.NoPath)
|
||||
{
|
||||
SetDirection(mover, steering, Vector2.Zero);
|
||||
steering.Status = SteeringStatus.InRange;
|
||||
return;
|
||||
}
|
||||
|
||||
// Can't move at all, just noop input.
|
||||
if (!mover.CanMove)
|
||||
{
|
||||
SetDirection(mover, steering, Vector2.Zero);
|
||||
steering.Status = SteeringStatus.Moving;
|
||||
return;
|
||||
}
|
||||
|
||||
// Grab the target position, either the next path node or our end goal.
|
||||
// TODO: Some situations we may not want to move at our target without a path.
|
||||
var targetCoordinates = GetTargetCoordinates(steering);
|
||||
var needsPath = false;
|
||||
|
||||
// If the next node is invalid then get new ones
|
||||
if (!targetCoordinates.IsValid(EntityManager))
|
||||
{
|
||||
if (steering.CurrentPath.TryPeek(out var poly) &&
|
||||
(poly.Data.Flags & PathfindingBreadcrumbFlag.Invalid) != 0x0)
|
||||
{
|
||||
steering.CurrentPath.Dequeue();
|
||||
// Try to get the next node temporarily.
|
||||
targetCoordinates = GetTargetCoordinates(steering);
|
||||
needsPath = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Need to be pretty close if it's just a node to make sure LOS for door bashes or the likes.
|
||||
float arrivalDistance;
|
||||
|
||||
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;
|
||||
}
|
||||
else
|
||||
{
|
||||
arrivalDistance = SharedInteractionSystem.InteractionRange - 0.8f;
|
||||
}
|
||||
|
||||
// Check if mapids match.
|
||||
var targetMap = targetCoordinates.ToMap(EntityManager);
|
||||
var ourMap = ourCoordinates.ToMap(EntityManager);
|
||||
|
||||
if (targetMap.MapId != ourMap.MapId)
|
||||
{
|
||||
SetDirection(mover, steering, Vector2.Zero);
|
||||
steering.Status = SteeringStatus.NoPath;
|
||||
return;
|
||||
}
|
||||
|
||||
var direction = targetMap.Position - ourMap.Position;
|
||||
// TODO: Pause time
|
||||
// Need it on the paused event which needs an engine PR.
|
||||
var nextSteer = steering.LastTimeSteer + TimeSpan.FromSeconds(1f / NPCSteeringComponent.SteerFrequency);
|
||||
|
||||
// Are we in range
|
||||
if (direction.Length <= arrivalDistance)
|
||||
if (nextSteer > _timing.CurTime)
|
||||
{
|
||||
// Node needs some kind of special handling like access or smashing.
|
||||
if (steering.CurrentPath.TryPeek(out var node))
|
||||
{
|
||||
var status = TryHandleFlags(steering, node, bodyQuery);
|
||||
|
||||
// TODO: Need to handle re-pathing in case the target moves around.
|
||||
switch (status)
|
||||
{
|
||||
case SteeringObstacleStatus.Completed:
|
||||
break;
|
||||
case SteeringObstacleStatus.Failed:
|
||||
// TODO: Blacklist the poly for next query
|
||||
SetDirection(mover, steering, Vector2.Zero);
|
||||
steering.Status = SteeringStatus.NoPath;
|
||||
return;
|
||||
case SteeringObstacleStatus.Continuing:
|
||||
SetDirection(mover, steering, Vector2.Zero, false);
|
||||
CheckPath(steering, xform, needsPath, distance);
|
||||
return;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise it's probably regular pathing so just keep going a bit more to get to tile centre
|
||||
if (direction.Length <= TileTolerance)
|
||||
{
|
||||
// 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, steering, Vector2.Zero);
|
||||
steering.Status = SteeringStatus.NoPath;
|
||||
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, steering, Vector2.Zero);
|
||||
steering.Status = SteeringStatus.InRange;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Do we have no more nodes to follow OR has the target moved sufficiently? If so then re-path.
|
||||
if (!needsPath)
|
||||
{
|
||||
needsPath = steering.CurrentPath.Count == 0 || (steering.CurrentPath.Peek().Data.Flags & PathfindingBreadcrumbFlag.Invalid) != 0x0;
|
||||
}
|
||||
|
||||
// TODO: Probably need partial planning support i.e. patch from the last node to where the target moved to.
|
||||
CheckPath(steering, xform, needsPath, distance);
|
||||
|
||||
if (steering.Pathfind && steering.CurrentPath.Count == 0)
|
||||
{
|
||||
SetDirection(mover, steering, Vector2.Zero, false);
|
||||
SetDirection(mover, steering, steering.LastSteer, false);
|
||||
return;
|
||||
}
|
||||
|
||||
modifierQuery.TryGetComponent(steering.Owner, out var modifier);
|
||||
steering.LastTimeSteer = _timing.CurTime;
|
||||
var uid = mover.Owner;
|
||||
var interest = steering.Interest;
|
||||
var danger = steering.Danger;
|
||||
var agentRadius = steering.Radius;
|
||||
var worldPos = xform.WorldPosition;
|
||||
|
||||
// Use rotation relative to parent to rotate our context vectors by.
|
||||
var offsetRot = -_mover.GetParentGridAngle(mover);
|
||||
modifierQuery.TryGetComponent(uid, out var modifier);
|
||||
var moveSpeed = GetSprintSpeed(steering.Owner, modifier);
|
||||
var tickMove = moveSpeed * frameTime;
|
||||
var body = bodyQuery.GetComponent(uid);
|
||||
var dangerPoints = steering.DangerPoints;
|
||||
dangerPoints.Clear();
|
||||
|
||||
var input = direction.Normalized;
|
||||
for (var i = 0; i < InterestDirections; i++)
|
||||
{
|
||||
steering.Interest[i] = 0f;
|
||||
steering.Danger[i] = 0f;
|
||||
}
|
||||
|
||||
// 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;
|
||||
var ev = new NPCSteeringEvent(steering, interest, danger, agentRadius, offsetRot, worldPos);
|
||||
RaiseLocalEvent(uid, ref ev);
|
||||
|
||||
if (tickMovement.Equals(0f))
|
||||
if (steering.CanSeek && !TrySeek(uid, mover, steering, body, xform, offsetRot, moveSpeed, interest, bodyQuery, frameTime))
|
||||
{
|
||||
SetDirection(mover, steering, Vector2.Zero);
|
||||
steering.Status = SteeringStatus.NoPath;
|
||||
return;
|
||||
}
|
||||
DebugTools.Assert(!float.IsNaN(interest[0]));
|
||||
|
||||
// We may overshoot slightly but still be in the arrival distance which is okay.
|
||||
var maxDistance = direction.Length + arrivalDistance;
|
||||
// Avoid static objects like walls
|
||||
CollisionAvoidance(uid, offsetRot, worldPos, agentRadius, tickMove, body, xform, danger, dangerPoints, bodyQuery, xformQuery);
|
||||
DebugTools.Assert(!float.IsNaN(danger[0]));
|
||||
|
||||
if (tickMovement > maxDistance)
|
||||
Separation(uid, offsetRot, worldPos, agentRadius, body, xform, interest, danger, bodyQuery, xformQuery);
|
||||
|
||||
// Remove the danger map from the interest map.
|
||||
var desiredDirection = -1;
|
||||
var desiredValue = 0f;
|
||||
|
||||
for (var i = 0; i < InterestDirections; i++)
|
||||
{
|
||||
input *= maxDistance / tickMovement;
|
||||
}
|
||||
var adjustedValue = Math.Clamp(interest[i] - danger[i], 0f, 1f);
|
||||
|
||||
// We have the input in world terms but need to convert it back to what movercontroller is doing.
|
||||
input = (-_mover.GetParentGridAngle(mover)).RotateVec(input);
|
||||
SetDirection(mover, steering, input);
|
||||
}
|
||||
|
||||
private void CheckPath(NPCSteeringComponent steering, TransformComponent xform, bool needsPath, float targetDistance)
|
||||
{
|
||||
if (!needsPath)
|
||||
{
|
||||
// If the target has sufficiently moved.
|
||||
var lastNode = GetCoordinates(steering.CurrentPath.Last());
|
||||
|
||||
if (lastNode.TryDistance(EntityManager, steering.Coordinates, out var lastDistance) &&
|
||||
lastDistance > steering.RepathRange)
|
||||
if (adjustedValue > desiredValue)
|
||||
{
|
||||
needsPath = true;
|
||||
desiredDirection = i;
|
||||
desiredValue = adjustedValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Request the new path.
|
||||
if (needsPath)
|
||||
var resultDirection = Vector2.Zero;
|
||||
|
||||
if (desiredDirection != -1)
|
||||
{
|
||||
RequestPath(steering, xform, targetDistance);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// We may be pathfinding and moving at the same time in which case early nodes may be out of date.
|
||||
/// </summary>
|
||||
public void PrunePath(MapCoordinates mapCoordinates, Vector2 direction, Queue<PathPoly> nodes)
|
||||
{
|
||||
if (nodes.Count == 0)
|
||||
return;
|
||||
|
||||
// Prune the first node as it's irrelevant.
|
||||
nodes.Dequeue();
|
||||
|
||||
while (nodes.TryPeek(out var node))
|
||||
{
|
||||
if (!node.Data.IsFreeSpace)
|
||||
break;
|
||||
|
||||
var nodeMap = node.Coordinates.ToMap(EntityManager);
|
||||
|
||||
// If any nodes are 'behind us' relative to the target we'll prune them.
|
||||
// This isn't perfect but should fix most cases of stutter stepping.
|
||||
if (nodeMap.MapId == mapCoordinates.MapId &&
|
||||
Vector2.Dot(direction, nodeMap.Position - mapCoordinates.Position) < 0f)
|
||||
{
|
||||
nodes.Dequeue();
|
||||
continue;
|
||||
}
|
||||
|
||||
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 GetCoordinates(nextTarget);
|
||||
resultDirection = new Angle(desiredDirection * InterestRadians).ToVec();
|
||||
}
|
||||
|
||||
return steering.Coordinates;
|
||||
DebugTools.Assert(!float.IsNaN(resultDirection.X));
|
||||
SetDirection(mover, steering, resultDirection, false);
|
||||
}
|
||||
|
||||
private EntityCoordinates GetCoordinates(PathPoly poly)
|
||||
@@ -435,11 +375,21 @@ namespace Content.Server.NPC.Systems
|
||||
if (steering.Pathfind || targetDistance < steering.RepathRange)
|
||||
return;
|
||||
|
||||
// Short-circuit with no path.
|
||||
var targetPoly = _pathfindingSystem.GetPoly(steering.Coordinates);
|
||||
|
||||
if (targetPoly != null && steering.Coordinates.Position.Equals(Vector2.Zero) && _interaction.InRangeUnobstructed(steering.Owner, steering.Coordinates.EntityId))
|
||||
{
|
||||
steering.CurrentPath.Clear();
|
||||
steering.CurrentPath.Enqueue(targetPoly);
|
||||
return;
|
||||
}
|
||||
|
||||
steering.PathfindToken = new CancellationTokenSource();
|
||||
|
||||
var flags = _pathfindingSystem.GetFlags(steering.Owner);
|
||||
|
||||
var result = await _pathfindingSystem.GetPath(
|
||||
var result = await _pathfindingSystem.GetPathSafe(
|
||||
steering.Owner,
|
||||
xform.Coordinates,
|
||||
steering.Coordinates,
|
||||
|
||||
Reference in New Issue
Block a user