using System.Collections.Generic;
using UnityEngine;
// using this namespace is necessary for Utilities AI Tasks
//
// The game adds UAI.UAITask to the class name for discover.
namespace UAI
{
public class UAITaskWanderSDXV4 : UAITaskWander
{
private string _interest;
// Task instances are singletons: one UAITaskWanderSDXV4 object is created per XML
// element at utilityai.xml parse time and shared by every entity using this package -
// there is no per-entity instance. Per-entity state (wander target, stuck count) must be
// keyed by entityId here, not stored as plain fields, or it gets overwritten/shared
// across every NPC wandering at the same time.
private readonly Dictionary _positions = new Dictionary();
private readonly Dictionary _stuckCounts = new Dictionary();
private readonly Dictionary _redirectCooldowns = new Dictionary();
private const int RedirectCooldownTicks = 30; // ~1.5s at Update()'s per-frame call rate - starting estimate, tune from in-game test if it feels too short/long
public override void initializeParameters()
{
base.initializeParameters();
if (this.Parameters.ContainsKey("interest"))
{
_interest = Parameters["interest"];
}
}
public override void Stop(Context _context)
{
_context.Self.getNavigator().clearPath();
_context.Self.moveHelper.CanBreakBlocks = true;
base.Stop(_context);
}
public override void Start(Context _context)
{
CombatUtils.SetCrouching(_context);
_context.Self.moveHelper.CanBreakBlocks = false;
// Start the action here, since we are just over-riding start, and not calling the base, as the base is calculating a hard coded 10 block path.
_context.ActionData.Started = true;
_context.ActionData.Executing = true;
// If max wander distance isn't set, set it now for 10.
if (maxWanderDistance == 0)
maxWanderDistance = 20;
// The y is lower than max wander, since they tend to try to climb up steep hills.
var position = RandomPositionGenerator.CalcAround(_context.Self, (int)maxWanderDistance, 10);
// CalcAround returns Vector3.zero when no valid position exists (e.g. unloaded chunks at
// biome spawn time). Pathing to world-origin always fails → BlockedTime rises → IsBlocked
// fires → Stop → UAI restarts Wander → tight freeze loop. Bail early instead.
if (position == Vector3.zero)
{
Stop(_context);
return;
}
// CalcAround already validated air + solid ground within 10 blocks below.
// Do NOT override Y with GetHeightAt — that returns natural terrain height only
// (ignores placed blocks), silently dragging the target down to ground level and
// routing the NPC off ledges/structures to reach it.
// If interests points have been specified, random roll to see if the npc will path towards them or not.
if (!string.IsNullOrEmpty(_interest))
{
var rand = _context.Self.rand.RandomRange(0.0f, 1.0f);
if (rand < 0.3)
{
var paths = ContainerUtils.ScanForTileEntities(_context, _interest);
if (paths.Count > 0)
{
var index = _context.Self.rand.RandomRange(0, paths.Count);
position = paths[index];
}
}
}
_positions[_context.Self.entityId] = position;
PathingUtils.FindPath(_context, position, false);
}
public override void Update(Context _context)
{
int entityId = _context.Self.entityId;
bool blocked = false;
if (_redirectCooldowns.TryGetValue(entityId, out int cooldown) && cooldown > 0)
{
_redirectCooldowns[entityId] = cooldown - 1;
// CheckJump can clear our path via EntityUtilities.Stop() when it detects an edge ahead.
// Only cut the cooldown short if CheckJump *actually* cleared a previously-valid path —
// not if the path was already dead from some other cause (which would just re-trigger
// the redirect loop and cause micro-turning jitter).
bool hadPath = !_context.Self.getNavigator().noPathAndNotPlanningOne();
PathingUtils.CheckJump(_context);
if (hadPath && _context.Self.getNavigator().noPathAndNotPlanningOne())
{
// CheckJump just cleared our path — re-issue it toward the same target instead
// of picking a new one. The target itself is fine, we just need to keep moving.
if (_positions.TryGetValue(entityId, out var currentTarget))
{
PathingUtils.FindPath(_context, currentTarget, false);
}
}
}
else
{
// Two independent ways this gets stuck: PathingUtils.IsBlocked() (collision/raycast -
// walked into something solid) and the navigator finding no path at all (e.g. an
// unreachable same-elevation target on a ledge). The vanilla base class
// (UAITaskWander.Update()) handled the second case with its own un-cooled Stop() call -
// we no longer call base.Update() (UAITaskBase.Update() is empty, so nothing else is
// lost) so BOTH cases funnel through the same cooldown-protected redirect logic
// instead of one bypassing it and re-triggering the Stop/Start whiplash.
blocked = PathingUtils.IsBlocked(_context) || _context.Self.getNavigator().noPathAndNotPlanningOne();
}
if (blocked)
{
_stuckCounts.TryGetValue(entityId, out int stuckCount);
stuckCount++;
_stuckCounts[entityId] = stuckCount;
Vector3 forward = _context.Self.GetForwardVector();
Vector3 newPosition = Vector3.zero;
bool foundGoodDirection = false;
// Widen the vertical search range on repeat blocks too - a ledge NPC can get stuck
// because every candidate within the normal Y range is at the same unreachable
// elevation. Capped so a persistently-stuck NPC doesn't end up searching wildly far
// above/below itself.
int yRange = 10 + Mathf.Min(stuckCount - 1, 4) * 5; // 10, 15, 20, 25, 30
int maxAttempts = stuckCount > 1 ? 5 : 1;
for (int attempt = 0; attempt < maxAttempts && !foundGoodDirection; attempt++)
{
var candidate = RandomPositionGenerator.CalcAround(_context.Self, (int)maxWanderDistance, yRange);
if (candidate == Vector3.zero) continue;
newPosition = candidate; // keep the most recent valid candidate as fallback
var toCandidate = candidate - _context.Self.position;
toCandidate.y = 0f;
if (toCandidate == Vector3.zero) continue;
foundGoodDirection = Vector3.Dot(toCandidate.normalized, forward) <= 0.3f;
}
if (newPosition == Vector3.zero)
{
Stop(_context);
return;
}
// Keep CalcAround's Y as-is — it already validated air + ground below. Overriding
// with GetHeightAt forces ground-level targets, which routes the NPC off ledges
// to reach them. Let the pathfinder work with the elevation CalcAround found.
_positions[entityId] = newPosition;
PathingUtils.FindPath(_context, newPosition, false);
_redirectCooldowns[entityId] = RedirectCooldownTicks;
return;
}
if (!_positions.TryGetValue(entityId, out var position))
return;
_context.Self.SetLookPosition(position);
var distance = Vector3.Distance(_context.Self.position, position);
if (distance < 0.5f)
{
_stuckCounts[entityId] = 0; // reached destination cleanly - reset escalation
_redirectCooldowns[entityId] = 0;
Stop(_context);
}
}
}
}