ECS NPCs (#9941)
* ECS * A * parity * Remove dummy update * abs * thanks rider
This commit is contained in:
42
Content.Server/AI/EntitySystems/NPCSystem.Blackboard.cs
Normal file
42
Content.Server/AI/EntitySystems/NPCSystem.Blackboard.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Content.Server.AI.Components;
|
||||
|
||||
namespace Content.Server.AI.EntitySystems;
|
||||
|
||||
public sealed partial class NPCSystem
|
||||
{
|
||||
/*
|
||||
/// <summary>
|
||||
/// Tries to get the blackboard data for a particular key. Returns default if not found
|
||||
/// </summary>
|
||||
public T? GetValueOrDefault<T>(NPCComponent component, string key)
|
||||
{
|
||||
if (component.BlackboardA.TryGetValue(key, out var value))
|
||||
{
|
||||
return (T) value;
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to get the blackboard data for a particular key.
|
||||
/// </summary>
|
||||
public bool TryGetValue<T>(NPCComponent component, string key, [NotNullWhen(true)] out T? value)
|
||||
{
|
||||
if (component.BlackboardA.TryGetValue(key, out var data))
|
||||
{
|
||||
value = (T) data;
|
||||
return true;
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
* Constants to make development easier
|
||||
*/
|
||||
|
||||
public const string VisionRadius = "VisionRadius";
|
||||
}
|
||||
284
Content.Server/AI/EntitySystems/NPCSystem.Utility.cs
Normal file
284
Content.Server/AI/EntitySystems/NPCSystem.Utility.cs
Normal file
@@ -0,0 +1,284 @@
|
||||
using System.Runtime.ExceptionServices;
|
||||
using System.Threading;
|
||||
using Content.Server.AI.Components;
|
||||
using Content.Server.AI.LoadBalancer;
|
||||
using Content.Server.AI.Operators;
|
||||
using Content.Server.AI.Utility;
|
||||
using Content.Server.AI.Utility.Actions;
|
||||
using Content.Server.AI.Utility.AiLogic;
|
||||
using Content.Server.AI.WorldState;
|
||||
using Content.Server.AI.WorldState.States.Utility;
|
||||
using Content.Server.CPUJob.JobQueues;
|
||||
using Content.Server.CPUJob.JobQueues.Queues;
|
||||
using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Reflection;
|
||||
|
||||
namespace Content.Server.AI.EntitySystems;
|
||||
|
||||
public sealed partial class NPCSystem
|
||||
{
|
||||
/*
|
||||
* Handles Utility AI, implemented via IAUS
|
||||
*/
|
||||
|
||||
private readonly NpcActionComparer _comparer = new();
|
||||
|
||||
private Dictionary<string, List<Type>> _behaviorSets = new();
|
||||
|
||||
private readonly AiActionJobQueue _aiRequestQueue = new();
|
||||
|
||||
private void InitializeUtility()
|
||||
{
|
||||
SubscribeLocalEvent<UtilityNPCComponent, ComponentStartup>(OnUtilityStartup);
|
||||
|
||||
foreach (var bSet in _prototypeManager.EnumeratePrototypes<BehaviorSetPrototype>())
|
||||
{
|
||||
var actions = new List<Type>();
|
||||
|
||||
foreach (var act in bSet.Actions)
|
||||
{
|
||||
if (!_reflectionManager.TryLooseGetType(act, out var parsedType) ||
|
||||
!typeof(IAiUtility).IsAssignableFrom(parsedType))
|
||||
{
|
||||
_sawmill.Error($"Unable to parse AI action for {act}");
|
||||
}
|
||||
else
|
||||
{
|
||||
actions.Add(parsedType);
|
||||
}
|
||||
}
|
||||
|
||||
_behaviorSets[bSet.ID] = actions;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnUtilityStartup(EntityUid uid, UtilityNPCComponent component, ComponentStartup args)
|
||||
{
|
||||
if (component.BehaviorSets.Count > 0)
|
||||
{
|
||||
RebuildActions(component);
|
||||
}
|
||||
|
||||
component._planCooldownRemaining = component.PlanCooldown;
|
||||
component._blackboard = new Blackboard(component.Owner);
|
||||
}
|
||||
|
||||
public AiActionRequestJob RequestAction(UtilityNPCComponent component, AiActionRequest request, CancellationTokenSource cancellationToken)
|
||||
{
|
||||
var job = new AiActionRequestJob(0.002, request, cancellationToken.Token);
|
||||
// AI should already know if it shouldn't request again
|
||||
_aiRequestQueue.EnqueueJob(job);
|
||||
return job;
|
||||
}
|
||||
|
||||
private void UpdateUtility(float frameTime)
|
||||
{
|
||||
foreach (var (_, comp) in EntityQuery<ActiveNPCComponent, UtilityNPCComponent>())
|
||||
{
|
||||
if (_count >= _maxUpdates) break;
|
||||
|
||||
Update(comp, frameTime);
|
||||
_count++;
|
||||
}
|
||||
|
||||
_aiRequestQueue.Process();
|
||||
}
|
||||
|
||||
private void ReceivedAction(UtilityNPCComponent component)
|
||||
{
|
||||
if (component._actionRequest == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (component._actionRequest.Exception)
|
||||
{
|
||||
case null:
|
||||
break;
|
||||
default:
|
||||
_sawmill.Fatal(component._actionRequest.Exception.ToString());
|
||||
ExceptionDispatchInfo.Capture(component._actionRequest.Exception).Throw();
|
||||
// The code never actually reaches here, because the above throws.
|
||||
// This is to tell the compiler that the flow never leaves here.
|
||||
throw component._actionRequest.Exception;
|
||||
}
|
||||
var action = component._actionRequest.Result;
|
||||
component._actionRequest = null;
|
||||
// Actions with lower scores should be implicitly dumped by GetAction
|
||||
// If we're not allowed to replace the action with an action of the same type then dump.
|
||||
if (action == null || !action.CanOverride && component.CurrentAction?.GetType() == action.GetType())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var currentOp = component.CurrentAction?.ActionOperators.Peek();
|
||||
if (currentOp != null && currentOp.HasStartup)
|
||||
{
|
||||
currentOp.Shutdown(Outcome.Failed);
|
||||
}
|
||||
|
||||
component.CurrentAction = action;
|
||||
action.SetupOperators(component._blackboard);
|
||||
}
|
||||
|
||||
private void Update(UtilityNPCComponent component, float frameTime)
|
||||
{
|
||||
// If we asked for a new action we don't want to dump the existing one.
|
||||
if (component._actionRequest != null)
|
||||
{
|
||||
if (component._actionRequest.Status != JobStatus.Finished)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ReceivedAction(component);
|
||||
// Do something next tick
|
||||
return;
|
||||
}
|
||||
|
||||
component._planCooldownRemaining -= frameTime;
|
||||
|
||||
// Might find a better action while we're doing one already
|
||||
if (component._planCooldownRemaining <= 0.0f)
|
||||
{
|
||||
component._planCooldownRemaining = component.PlanCooldown;
|
||||
component._actionCancellation = new CancellationTokenSource();
|
||||
component._actionRequest = RequestAction(component, new AiActionRequest(component.Owner, component._blackboard, component.AvailableActions), component._actionCancellation);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// When we spawn in we won't get an action for a bit
|
||||
if (component.CurrentAction == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var outcome = component.CurrentAction.Execute(frameTime);
|
||||
|
||||
switch (outcome)
|
||||
{
|
||||
case Outcome.Success:
|
||||
if (component.CurrentAction.ActionOperators.Count == 0)
|
||||
{
|
||||
component.CurrentAction.Shutdown();
|
||||
component.CurrentAction = null;
|
||||
// Nothing to compare new action to
|
||||
component._blackboard.GetState<LastUtilityScoreState>().SetValue(0.0f);
|
||||
}
|
||||
break;
|
||||
case Outcome.Continuing:
|
||||
break;
|
||||
case Outcome.Failed:
|
||||
component.CurrentAction.Shutdown();
|
||||
component.CurrentAction = null;
|
||||
component._blackboard.GetState<LastUtilityScoreState>().SetValue(0.0f);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the BehaviorSet to the NPC.
|
||||
/// </summary>
|
||||
/// <param name="npc"></param>
|
||||
/// <param name="behaviorSet"></param>
|
||||
/// <param name="rebuild">Set to false if you want to manually rebuild it after bulk updates.</param>
|
||||
public void AddBehaviorSet(UtilityNPCComponent npc, string behaviorSet, bool rebuild = true)
|
||||
{
|
||||
if (!_behaviorSets.ContainsKey(behaviorSet))
|
||||
{
|
||||
_sawmill.Error($"Tried to add BehaviorSet {behaviorSet} to {npc} but no such BehaviorSet found!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!npc.BehaviorSets.Add(behaviorSet))
|
||||
{
|
||||
_sawmill.Error($"Tried to add BehaviorSet {behaviorSet} to {npc} which already has the BehaviorSet!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (rebuild)
|
||||
RebuildActions(npc);
|
||||
|
||||
if (npc.BehaviorSets.Count == 1 && !IsAwake(npc))
|
||||
WakeNPC(npc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the BehaviorSet from the NPC.
|
||||
/// </summary>
|
||||
/// <param name="npc"></param>
|
||||
/// <param name="behaviorSet"></param>
|
||||
/// <param name="rebuild">Set to false if yo uwant to manually rebuild it after bulk updates.</param>
|
||||
public void RemoveBehaviorSet(UtilityNPCComponent npc, string behaviorSet, bool rebuild = true)
|
||||
{
|
||||
if (!_behaviorSets.TryGetValue(behaviorSet, out var actions))
|
||||
{
|
||||
Logger.Error($"Tried to remove BehaviorSet {behaviorSet} from {npc} but no such BehaviorSet found!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!npc.BehaviorSets.Remove(behaviorSet))
|
||||
{
|
||||
Logger.Error($"Tried to remove BehaviorSet {behaviorSet} from {npc} but it doesn't have that BehaviorSet!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (rebuild)
|
||||
RebuildActions(npc);
|
||||
|
||||
if (npc.BehaviorSets.Count == 0)
|
||||
SleepNPC(npc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear our actions and re-instantiate them from our BehaviorSets.
|
||||
/// Will ensure each action is unique.
|
||||
/// </summary>
|
||||
/// <param name="npc"></param>
|
||||
public void RebuildActions(UtilityNPCComponent npc)
|
||||
{
|
||||
npc.AvailableActions.Clear();
|
||||
foreach (var bSet in npc.BehaviorSets)
|
||||
{
|
||||
foreach (var action in GetActions(bSet))
|
||||
{
|
||||
if (npc.AvailableActions.Contains(action)) continue;
|
||||
// Setup
|
||||
action.Owner = npc.Owner;
|
||||
|
||||
// Ad to actions.
|
||||
npc.AvailableActions.Add(action);
|
||||
}
|
||||
}
|
||||
|
||||
SortActions(npc);
|
||||
}
|
||||
|
||||
private IEnumerable<IAiUtility> GetActions(string behaviorSet)
|
||||
{
|
||||
foreach (var action in _behaviorSets[behaviorSet])
|
||||
{
|
||||
yield return (IAiUtility) _typeFactory.CreateInstance(action);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whenever the behavior sets are changed we'll re-sort the actions by bonus
|
||||
/// </summary>
|
||||
private void SortActions(UtilityNPCComponent npc)
|
||||
{
|
||||
npc.AvailableActions.Sort(_comparer);
|
||||
}
|
||||
|
||||
private sealed class NpcActionComparer : Comparer<IAiUtility>
|
||||
{
|
||||
public override int Compare(IAiUtility? x, IAiUtility? y)
|
||||
{
|
||||
if (x == null || y == null) return 0;
|
||||
return y.Bonus.CompareTo(x.Bonus);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Linq;
|
||||
using Content.Server.AI.Components;
|
||||
using Content.Shared.CCVar;
|
||||
using Content.Shared.MobState;
|
||||
using JetBrains.Annotations;
|
||||
using Robust.Shared.Configuration;
|
||||
using Robust.Shared.Random;
|
||||
using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Reflection;
|
||||
|
||||
namespace Content.Server.AI.EntitySystems
|
||||
{
|
||||
@@ -12,120 +12,103 @@ namespace Content.Server.AI.EntitySystems
|
||||
/// Handles NPCs running every tick.
|
||||
/// </summary>
|
||||
[UsedImplicitly]
|
||||
internal sealed class NPCSystem : EntitySystem
|
||||
public sealed partial class NPCSystem : EntitySystem
|
||||
{
|
||||
[Dependency] private readonly IConfigurationManager _configurationManager = default!;
|
||||
[Dependency] private readonly IRobustRandom _robustRandom = default!;
|
||||
[Dependency] private readonly IDynamicTypeFactory _typeFactory = default!;
|
||||
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
|
||||
[Dependency] private readonly IReflectionManager _reflectionManager = default!;
|
||||
|
||||
/// <summary>
|
||||
/// To avoid iterating over dead AI continuously they can wake and sleep themselves when necessary.
|
||||
/// </summary>
|
||||
private readonly HashSet<AiControllerComponent> _awakeNPCs = new();
|
||||
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();
|
||||
SubscribeLocalEvent<AiControllerComponent, MobStateChangedEvent>(OnMobStateChange);
|
||||
SubscribeLocalEvent<AiControllerComponent, ComponentInit>(OnNPCInit);
|
||||
SubscribeLocalEvent<AiControllerComponent, ComponentShutdown>(OnNPCShutdown);
|
||||
_sawmill = Logger.GetSawmill("npc");
|
||||
InitializeUtility();
|
||||
SubscribeLocalEvent<NPCComponent, MobStateChangedEvent>(OnMobStateChange);
|
||||
SubscribeLocalEvent<NPCComponent, ComponentInit>(OnNPCInit);
|
||||
SubscribeLocalEvent<NPCComponent, ComponentShutdown>(OnNPCShutdown);
|
||||
_configurationManager.OnValueChanged(CCVars.NPCEnabled, SetEnabled, true);
|
||||
|
||||
var maxUpdates = _configurationManager.GetCVar(CCVars.NPCMaxUpdates);
|
||||
|
||||
if (maxUpdates < 1024)
|
||||
_awakeNPCs.EnsureCapacity(maxUpdates);
|
||||
_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 OnNPCInit(EntityUid uid, AiControllerComponent component, ComponentInit args)
|
||||
private void OnNPCInit(EntityUid uid, NPCComponent component, ComponentInit args)
|
||||
{
|
||||
if (!component.Awake) return;
|
||||
|
||||
_awakeNPCs.Add(component);
|
||||
WakeNPC(component);
|
||||
}
|
||||
|
||||
private void OnNPCShutdown(EntityUid uid, AiControllerComponent component, ComponentShutdown args)
|
||||
private void OnNPCShutdown(EntityUid uid, NPCComponent component, ComponentShutdown args)
|
||||
{
|
||||
_awakeNPCs.Remove(component);
|
||||
SleepNPC(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>
|
||||
/// <param name="component"></param>
|
||||
public void WakeNPC(AiControllerComponent component)
|
||||
public void WakeNPC(NPCComponent component)
|
||||
{
|
||||
_awakeNPCs.Add(component);
|
||||
_sawmill.Debug($"Waking {ToPrettyString(component.Owner)}");
|
||||
EnsureComp<ActiveNPCComponent>(component.Owner);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the NPC from actively being updated.
|
||||
/// </summary>
|
||||
/// <param name="component"></param>
|
||||
public void SleepNPC(AiControllerComponent component)
|
||||
public void SleepNPC(NPCComponent component)
|
||||
{
|
||||
_awakeNPCs.Remove(component);
|
||||
_sawmill.Debug($"Sleeping {ToPrettyString(component.Owner)}");
|
||||
RemComp<ActiveNPCComponent>(component.Owner);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Update(float frameTime)
|
||||
{
|
||||
base.Update(frameTime);
|
||||
if (!Enabled) return;
|
||||
|
||||
var cvarMaxUpdates = _configurationManager.GetCVar(CCVars.NPCMaxUpdates);
|
||||
|
||||
if (cvarMaxUpdates <= 0) return;
|
||||
|
||||
var npcs = _awakeNPCs.ToArray();
|
||||
var startIndex = 0;
|
||||
|
||||
// If we're overcap we'll just update randomly so they all still at least do something
|
||||
// Didn't randomise the array (even though it'd probably be better) because god damn that'd be expensive.
|
||||
if (npcs.Length > cvarMaxUpdates)
|
||||
{
|
||||
startIndex = _robustRandom.Next(npcs.Length);
|
||||
}
|
||||
|
||||
for (var i = 0; i < npcs.Length; i++)
|
||||
{
|
||||
MetaDataComponent? metadata = null;
|
||||
var index = (i + startIndex) % npcs.Length;
|
||||
var npc = npcs[index];
|
||||
|
||||
if (Deleted(npc.Owner, metadata))
|
||||
continue;
|
||||
|
||||
// Probably gets resolved in deleted for us already
|
||||
if (Paused(npc.Owner, metadata))
|
||||
continue;
|
||||
|
||||
npc.Update(frameTime);
|
||||
}
|
||||
_count = 0;
|
||||
UpdateUtility(frameTime);
|
||||
}
|
||||
|
||||
private void OnMobStateChange(EntityUid uid, AiControllerComponent component, MobStateChangedEvent args)
|
||||
private void OnMobStateChange(EntityUid uid, NPCComponent component, MobStateChangedEvent args)
|
||||
{
|
||||
switch (args.CurrentMobState)
|
||||
{
|
||||
case DamageState.Alive:
|
||||
component.Awake = true;
|
||||
WakeNPC(component);
|
||||
break;
|
||||
case DamageState.Critical:
|
||||
case DamageState.Dead:
|
||||
component.Awake = false;
|
||||
SleepNPC(component);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user