Splits creature speed into two clocks so movement pace can be tuned without touching reaction time: - **Think clock** — `ActiveSpeed`/`PassiveSpeed`/`CurrentSpeed`: seconds per AI decision. Unchanged in meaning, storage, and cadence. - **Move clock** — `ActiveMoveSpeed`/`PassiveMoveSpeed` (+ resolved `CurrentMoveSpeed`): seconds per step. `0` = inherit the matching think value. ### How - Move speeds come from optional `activeMove`/`passiveMove` in `npc-speeds.json`, are `[props`-tunable per instance (set `0` to re-inherit), and serialize (BaseCreature v22). - `SetSpeed()` keeps its legacy one-clock semantics — sets the think clock **and clears move overrides** — so existing callers cannot half-configure a creature. `SetMoveSpeed()`/`ClearMoveSpeed()` configure movement explicitly; `ScaleMoveSpeed()` scales overrides for buffs. - `CurrentMoveSpeed` is derived by classifying `CurrentSpeed`: a verbatim active/passive think value maps to the matching move value; a bespoke pace written directly (mount boosts, follow sprint) stays fused to both clocks. External `CurrentSpeed` writers need no changes. - `AITimer` schedules the earlier of the two deadlines. Decisions run at the think cadence exactly as before; while a pursuit/investigation is live, the timer also wakes when the movement budget elapses and advances one step with no decisions. Steps no longer snap to the think grid, so any step delay paces smoothly on the 8ms wheel. A blocked creature schedules no move wakes. - The movement budget is RunUO's `m_NextMove` accumulate-and-clamp at a full step, so long-run pacing averages `CurrentMoveSpeed` exactly. ### Behavior changes - **`npc-speeds.json` buckets get RunUO `TransformMoveDelay`-parity move values**: creatures step at RunUO pace while thinking/reacting at current speed. The situational +0.1/+0.2 offsets are deliberately omitted. - **Existing saves migrate on load**: a pre-v22 creature whose think speeds still match its npc-speeds entry (never hand-tuned) adopts the table's move values — worlds and pets pick up the new pacing without a respawn. Tuned creatures keep movement inheriting their think clock. - **Paragons scale movement by `SpeedBuff` (1.2x)**: RunUO had no deliberate policy here — dividing by 1.2 knocked most speeds off `TransformMoveDelay`'s exact-equality table (raw pass-through, 2x+ faster), while 0.3/0.6 creatures landed back on it for ~1.33x. This applies the uniform 1.2x the buff always claimed. UnConvert snaps speeds back to exact table values within 1e-4 — /1.2 then ×1.2 drifts 0.45 and 0.9 by an ulp, which would read as hand-tuned (and defeat a future skip-table-conformant-values serialization pass); tuned speeds keep. - **Herding paces the movement clock**: the old `CurrentSpeed` getter hack is gone. A herded creature walks at a fixed 0.3s/step — RunUO's forced pace, without its `TransformMoveDelay` inflation to 0.6 — so herding is never penalized by a slow creature. Thinking is untouched, and `CheckHerding` walks through `MoveToPoint`, so herded creatures path around obstacles. - **Badly-hurt slowdown now inflates the step delay only** (RunUO parity), computed from the base each step. Previously it wrote `CurrentSpeed = CurrentSpeed + 0.05..0.15` back on every successful step — compounding unboundedly while hurt and slowing decisions too. - Removes the vestigial `MoveSpeedMod` (never read, written, or serialized). - With no bucket or per-instance move values, both clocks carry identical values and creatures pace as before. ### Testing - Full suite passes (1557, including 12 new `MoveSpeedTests`: resolution classes, `SetSpeed` clearing, `0`-re-inherit, v22 round-trip with exact-consumption check, save migration adopt/skip, buff scale/snap, herding). - In-game verified via local diagnostics build (per-step budget tracing): steady 700ms step cadence on a 0.3s think grid with one-step catch-up after idle, think grid unperturbed by move wakes.
689 lines
21 KiB
C#
689 lines
21 KiB
C#
/*************************************************************************
|
|
* ModernUO *
|
|
* Copyright 2019-2026 - ModernUO Development Team *
|
|
* Email: hi@modernuo.com *
|
|
* File: AIMovement.cs *
|
|
* *
|
|
* This program is free software: you can redistribute it and/or modify *
|
|
* it under the terms of the GNU General Public License as published by *
|
|
* the Free Software Foundation, either version 3 of the License, or *
|
|
* (at your option) any later version. *
|
|
* *
|
|
* You should have received a copy of the GNU General Public License *
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
|
************************************************************************/
|
|
|
|
using System;
|
|
using System.Runtime.CompilerServices;
|
|
using Server.Collections;
|
|
using Server.Items;
|
|
using MoveImpl = Server.Movement.MovementImpl;
|
|
|
|
namespace Server.Mobiles;
|
|
|
|
public abstract partial class BaseAI
|
|
{
|
|
// --- Centralized progress-based approach state (see ApproachTarget) ---------------
|
|
// Consecutive move-eligible ticks a creature may fail to improve its best distance to a
|
|
// STATIONARY goal before it gives up and idles. A moving goal (an active chase) never
|
|
// triggers give-up. Must exceed the longest no-improvement stretch of a valid detour
|
|
// (the Britain Inn detour's is ~13 ticks), with margin; this also bounds the largest
|
|
// concave detour a creature will navigate before idling on a stationary goal.
|
|
private const int ApproachGiveUpTicks = 40;
|
|
|
|
private Mobile _approachGoal;
|
|
private Point3D _approachGoalLoc;
|
|
private double _approachBestDist;
|
|
private int _approachStallTicks;
|
|
private bool _approachGaveUp;
|
|
private Point3D _approachGaveUpGoalLoc;
|
|
|
|
// --- Move intent (see ContinueMove) ------------------------------------------------
|
|
// Durable movement goal renewed by en-route ApproachTarget/MoveToPoint calls; while
|
|
// live, the AITimer wakes at NextMove between think ticks to advance the step.
|
|
private Mobile _moveIntentTarget;
|
|
private IPoint3D _moveIntentPoint;
|
|
private bool _moveIntentRun;
|
|
private int _moveIntentRange;
|
|
private long _moveIntentExpire;
|
|
|
|
// Inflates a step delay while badly hurt; computed from the passed base so it cannot
|
|
// compound across steps. Damage slows steps, never decisions.
|
|
public static double BadlyHurtMoveDelay(BaseCreature bc, double delay)
|
|
{
|
|
var statMin = Core.HS ? bc.Stam : bc.Hits;
|
|
var statMax = Core.HS ? bc.StamMax : bc.HitsMax;
|
|
|
|
if (!bc.IsDeadPet && (bc.ReduceSpeedWithDamage || bc.IsSubdued)
|
|
&& statMax > 0 && statMin < statMax * 0.3)
|
|
{
|
|
var stat = (double)statMin / statMax;
|
|
|
|
if (stat < 0.1) { return delay + 0.15; }
|
|
if (stat < 0.2) { return delay + 0.1; }
|
|
|
|
return delay + 0.05;
|
|
}
|
|
|
|
return delay;
|
|
}
|
|
|
|
public bool CanMoveNow(out double delay)
|
|
{
|
|
delay = 0.0;
|
|
return Core.TickCount - NextMove >= 0;
|
|
}
|
|
|
|
// Accumulative full-step budget: long-run pacing averages CurrentMoveSpeed exactly
|
|
// regardless of timer-grid jitter; snap-to-now caps stall catch-up at one step.
|
|
private void ConsumeMoveBudget()
|
|
{
|
|
var stepDelay = Mobile.CurrentMoveSpeed;
|
|
|
|
if (!(Core.AOS && IsFollowingMaster()))
|
|
{
|
|
stepDelay = BadlyHurtMoveDelay(Mobile, stepDelay);
|
|
}
|
|
|
|
NextMove += Math.Max(50, (long)(stepDelay * 1000));
|
|
|
|
if (Core.TickCount - NextMove > 0)
|
|
{
|
|
NextMove = Core.TickCount;
|
|
}
|
|
}
|
|
|
|
public virtual bool CheckMove() => !(Mobile.Deleted || Mobile.DisallowAllMoves);
|
|
|
|
public virtual bool DoMove(Direction d, bool badStateOk = false) => IsMoveSuccessful(DoMoveImpl(d, badStateOk), badStateOk);
|
|
|
|
private static bool IsMoveSuccessful(MoveResult res, bool badStateOk) =>
|
|
res is MoveResult.Success or MoveResult.SuccessAutoTurn
|
|
|| badStateOk && res == MoveResult.BadState;
|
|
|
|
public virtual MoveResult DoMoveImpl(Direction d, bool badStateOk)
|
|
{
|
|
if (IsInBadState() || !CanMoveNow(out _))
|
|
{
|
|
return MoveResult.BadState;
|
|
}
|
|
|
|
if ((Mobile.Direction & Direction.Mask) != (d & Direction.Mask))
|
|
{
|
|
Mobile.Direction = d;
|
|
}
|
|
|
|
Mobile.Pushing = false;
|
|
var mobDirection = Mobile.Direction;
|
|
|
|
if (TryMove(d))
|
|
{
|
|
// Writes the think clock only; hurt slowdown applies in ConsumeMoveBudget.
|
|
if (Core.AOS && IsFollowingMaster())
|
|
{
|
|
Mobile.CurrentSpeed = 0.1;
|
|
}
|
|
else if (Mobile.Warmode || Mobile.Combatant != null)
|
|
{
|
|
Mobile.SetCurrentSpeedToActive();
|
|
}
|
|
else
|
|
{
|
|
Mobile.SetCurrentSpeedToPassive();
|
|
}
|
|
|
|
ConsumeMoveBudget();
|
|
|
|
return MoveResult.Success;
|
|
}
|
|
|
|
if ((mobDirection & Direction.Mask) != (d & Direction.Mask))
|
|
{
|
|
Mobile.Direction = d;
|
|
return MoveResult.SuccessAutoTurn;
|
|
}
|
|
|
|
return HandleBlockedMovement(d);
|
|
}
|
|
|
|
private bool TryMove(Direction d)
|
|
{
|
|
MoveImpl.IgnoreMovableImpassables = Mobile.CanMoveOverObstacles && !Mobile.CanDestroyObstacles;
|
|
|
|
var result = Mobile.Move(d);
|
|
|
|
MoveImpl.IgnoreMovableImpassables = false;
|
|
return result;
|
|
}
|
|
|
|
private bool IsInBadState() =>
|
|
Mobile == null || Mobile.Deleted || Mobile.Frozen || Mobile.Paralyzed ||
|
|
Mobile.Spell?.IsCasting == true || Mobile.DisallowAllMoves;
|
|
|
|
private MoveResult HandleBlockedMovement(Direction d)
|
|
{
|
|
var wasPushing = Mobile.Pushing;
|
|
|
|
if ((Mobile.CanOpenDoors || Mobile.CanDestroyObstacles) && !TryClearObstacles(d))
|
|
{
|
|
return MoveResult.Success;
|
|
}
|
|
|
|
return TryAlternateMovement(wasPushing);
|
|
}
|
|
|
|
private MoveResult TryAlternateMovement(bool wasPushing)
|
|
{
|
|
var offset = Utility.Random(2) == 0 ? 1 : -1;
|
|
|
|
for (var i = 0; i < 2; ++i)
|
|
{
|
|
Mobile.TurnInternal(offset);
|
|
|
|
if (Mobile.Move(Mobile.Direction))
|
|
{
|
|
ConsumeMoveBudget();
|
|
return MoveResult.SuccessAutoTurn;
|
|
}
|
|
}
|
|
|
|
return wasPushing ? MoveResult.BadState : MoveResult.Blocked;
|
|
}
|
|
|
|
private bool TryClearObstacles(Direction d)
|
|
{
|
|
DebugSay("My movement is blocked. Trying to push through.");
|
|
|
|
var map = Mobile.Map;
|
|
|
|
if (map == null) { return true; }
|
|
|
|
var (x, y) = GetOffsetLocation(d);
|
|
|
|
var queue = GatherObstacles(x, y, out var destroyables);
|
|
|
|
if (destroyables > 0)
|
|
{
|
|
Effects.PlaySound(new Point3D(x, y, Mobile.Z), Mobile.Map, 0x3B3);
|
|
}
|
|
|
|
try
|
|
{
|
|
return ProcessObstacles(ref queue, d);
|
|
}
|
|
finally
|
|
{
|
|
queue.Dispose();
|
|
}
|
|
}
|
|
|
|
private (int x, int y) GetOffsetLocation(Direction d)
|
|
{
|
|
var x = Mobile.X;
|
|
var y = Mobile.Y;
|
|
Movement.Movement.Offset(d, ref x, ref y);
|
|
return (x, y);
|
|
}
|
|
|
|
private PooledRefQueue<Item> GatherObstacles(int x, int y, out int destroyables)
|
|
{
|
|
var queue = PooledRefQueue<Item>.Create();
|
|
destroyables = 0;
|
|
|
|
foreach (var item in Mobile.Map.GetItemsInRange(new Point2D(x, y), 1))
|
|
{
|
|
if (IsValidDoor(item, x, y) || IsValidDestroyableItem(item))
|
|
{
|
|
queue.Enqueue(item);
|
|
if (item is not BaseDoor)
|
|
{
|
|
destroyables++;
|
|
}
|
|
}
|
|
}
|
|
|
|
return queue;
|
|
}
|
|
|
|
private bool IsValidDoor(Item item, int x, int y)
|
|
{
|
|
if (!Mobile.CanOpenDoors || item is not BaseDoor door)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (door.Z + door.ItemData.Height <= Mobile.Z || Mobile.Z + 16 <= door.Z)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (door.X != x || door.Y != y)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return !door.Locked || !door.UseLocks();
|
|
}
|
|
|
|
private bool IsValidDestroyableItem(Item item)
|
|
{
|
|
if (!Mobile.CanDestroyObstacles || !item.Movable || !item.ItemData.Impassable)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (item.Z + item.ItemData.Height <= Mobile.Z || Mobile.Z + 16 <= item.Z)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return Mobile.InRange(item.GetWorldLocation(), 1);
|
|
}
|
|
|
|
private bool ProcessObstacles(ref PooledRefQueue<Item> queue, Direction d)
|
|
{
|
|
if (queue.Count == 0) { return true; }
|
|
|
|
while (queue.Count > 0)
|
|
{
|
|
ProcessObstacle(queue.Dequeue(), ref queue);
|
|
}
|
|
|
|
return !Mobile.Move(d);
|
|
}
|
|
|
|
private void ProcessObstacle(Item item, ref PooledRefQueue<Item> queue)
|
|
{
|
|
if (item is BaseDoor door)
|
|
{
|
|
DebugSay("Opening the door.");
|
|
door.Use(Mobile);
|
|
}
|
|
else
|
|
{
|
|
this.DebugSayFormatted($"Destroying item: {item.GetType().Name}");
|
|
|
|
if (item is Container cont)
|
|
{
|
|
ProcessContainer(cont, ref queue);
|
|
cont.Destroy();
|
|
}
|
|
else
|
|
{
|
|
item.Delete();
|
|
}
|
|
}
|
|
}
|
|
|
|
private void ProcessContainer(Container cont, ref PooledRefQueue<Item> queue)
|
|
{
|
|
foreach (var check in cont.Items)
|
|
{
|
|
if (check.Movable && check.ItemData.Impassable && cont.Z + check.ItemData.Height > Mobile.Z)
|
|
{
|
|
queue.Enqueue(check);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Centralized "move toward <paramref name="target"/> until within
|
|
/// <paramref name="range"/>" decision shared by MoveTo and WalkMobileRange. A greedy
|
|
/// step is taken only when it actually gets the creature closer; a blocked step or an
|
|
/// auto-turn sidestep that made no progress falls through to a persistent PathFollower
|
|
/// that routes around the obstacle and is never discarded by a greedy step. A
|
|
/// best-distance stall counter idles the creature if an in-range goal is genuinely
|
|
/// unreachable, without ever abandoning a real chase or detour.
|
|
/// </summary>
|
|
protected bool ApproachTarget(Mobile target, bool run, int range)
|
|
{
|
|
if (Mobile.Deleted || Mobile.DisallowAllMoves || target?.Deleted != false)
|
|
{
|
|
ClearMoveIntent();
|
|
return false;
|
|
}
|
|
|
|
if (Mobile.InRange(target, range))
|
|
{
|
|
ResetApproach();
|
|
ClearMoveIntent();
|
|
return true;
|
|
}
|
|
|
|
// Already gave up on this exact (unreachable) goal: idle until it moves.
|
|
if (_approachGaveUp && _approachGoal == target)
|
|
{
|
|
if (target.Location == _approachGaveUpGoalLoc)
|
|
{
|
|
ClearMoveIntent();
|
|
return false;
|
|
}
|
|
|
|
ResetApproach(); // target moved — try again fresh
|
|
}
|
|
|
|
RenewMoveIntent(target, null, run, range);
|
|
|
|
// FAST PATH: greedy step toward the target, counted as success ONLY when the move
|
|
// fully succeeded (not an auto-turn sidestep) and actually got us closer. An
|
|
// auto-turn sidestep can reduce Euclidean distance while moving in the wrong
|
|
// direction (e.g., east when the true route requires going south-first around a
|
|
// concave obstacle); treating it as progress would discard a PathFollower that is
|
|
// the only way to navigate. A blocked step or a non-Success result falls through to
|
|
// the planner immediately.
|
|
if (Path == null && Mobile.InLOS(target))
|
|
{
|
|
var distBefore = Mobile.GetDistanceToSqrt(target);
|
|
var res = DoMoveImpl(Mobile.GetDirectionTo(target, run), true);
|
|
|
|
if (res == MoveResult.BadState)
|
|
{
|
|
return true; // not allowed to move this tick (frozen/casting/throttled); not a failure
|
|
}
|
|
|
|
if (res == MoveResult.Success && Mobile.GetDistanceToSqrt(target) < distBefore)
|
|
{
|
|
|
|
ResetApproach();
|
|
return true; // healthy en-route progress
|
|
}
|
|
|
|
// else: fall through; let the PathFollower route around the obstacle.
|
|
}
|
|
|
|
// PLANNING PATH: a persistent PathFollower, never discarded by a greedy step.
|
|
if (Path == null || Path.Goal != target)
|
|
{
|
|
|
|
Path = new PathFollower(Mobile, target) { Mover = DoMoveImpl };
|
|
}
|
|
|
|
// Sample move-eligibility BEFORE the attempt: a successful step consumes the move
|
|
// budget, which would mask stall accounting and the progress signal.
|
|
var couldMove = CanMoveNow(out _) && !IsInBadState();
|
|
var locBefore = Mobile.Location;
|
|
|
|
if (Path.Follow(run, range))
|
|
{
|
|
ResetApproach();
|
|
return true;
|
|
}
|
|
|
|
TrackApproachProgress(target, couldMove);
|
|
|
|
// En-route progress is success; failure only when a move-eligible tick took no step
|
|
// (no working path), or the approach has given up.
|
|
var progressed = !_approachGaveUp && (Mobile.Location != locBefore || !couldMove);
|
|
|
|
return progressed;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Walks toward a fixed point (e.g. a target's last-known position), pathfinding around
|
|
/// obstacles. Returns false on arrival or when genuinely unable to make progress.
|
|
/// </summary>
|
|
public bool MoveToPoint(IPoint3D goal, bool run)
|
|
{
|
|
if (Mobile.Deleted || Mobile.DisallowAllMoves || goal == null)
|
|
{
|
|
ClearMoveIntent();
|
|
return false;
|
|
}
|
|
|
|
if (Path?.Goal != goal)
|
|
{
|
|
Path = new PathFollower(Mobile, goal) { Mover = DoMoveImpl };
|
|
}
|
|
|
|
RenewMoveIntent(null, goal, run, 1);
|
|
|
|
var couldMove = CanMoveNow(out _) && !IsInBadState();
|
|
var locBefore = Mobile.Location;
|
|
|
|
if (Path.Follow(run, 1))
|
|
{
|
|
Path = null;
|
|
ClearMoveIntent();
|
|
return false; // arrived
|
|
}
|
|
|
|
var progressed = Mobile.Location != locBefore || !couldMove;
|
|
|
|
if (!progressed)
|
|
{
|
|
ClearMoveIntent();
|
|
}
|
|
|
|
return progressed;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Best-distance stuck detection. A creature making real headway keeps lowering its
|
|
/// closest-ever distance to the goal (a detour's outbound leg pauses that, but it
|
|
/// resumes once the creature rounds the obstacle). A creature that cannot reach a
|
|
/// STATIONARY goal never lowers it and, after <see cref="ApproachGiveUpTicks"/> ticks,
|
|
/// gives up and idles. A MOVING goal (an active chase) resets the baseline every tick,
|
|
/// so chases never give up even when the gap holds constant.
|
|
/// </summary>
|
|
private void TrackApproachProgress(Mobile target, bool couldMove)
|
|
{
|
|
if (!couldMove)
|
|
{
|
|
return; // a tick that was never allowed to move (stun, stall) is not a stall
|
|
}
|
|
|
|
var dist = Mobile.GetDistanceToSqrt(target);
|
|
var goalLoc = target.Location;
|
|
|
|
// New goal, or the goal moved (active chase): reset the stall baseline. Clearing the
|
|
// give-up flag here prevents a prior goal's give-up state from leaking onto a new one.
|
|
if (_approachGoal != target || goalLoc != _approachGoalLoc)
|
|
{
|
|
_approachGoal = target;
|
|
_approachGoalLoc = goalLoc;
|
|
_approachBestDist = dist;
|
|
_approachStallTicks = 0;
|
|
_approachGaveUp = false;
|
|
return;
|
|
}
|
|
|
|
// Stationary goal: getting closer than ever resets the stall.
|
|
if (dist < _approachBestDist)
|
|
{
|
|
_approachBestDist = dist;
|
|
_approachStallTicks = 0;
|
|
return;
|
|
}
|
|
|
|
if (++_approachStallTicks >= ApproachGiveUpTicks)
|
|
{
|
|
_approachGaveUp = true;
|
|
_approachGaveUpGoalLoc = goalLoc;
|
|
Path = null;
|
|
ClearMoveIntent();
|
|
}
|
|
}
|
|
|
|
/// <summary>Clears all approach state (called on arrival, real greedy progress, or when
|
|
/// a given-up goal moves).</summary>
|
|
private void ResetApproach()
|
|
{
|
|
Path = null;
|
|
_approachGoal = null;
|
|
_approachGoalLoc = Point3D.Zero;
|
|
_approachBestDist = 0;
|
|
_approachStallTicks = 0;
|
|
_approachGaveUp = false;
|
|
}
|
|
|
|
private void RenewMoveIntent(Mobile target, IPoint3D point, bool run, int range)
|
|
{
|
|
_moveIntentTarget = target;
|
|
_moveIntentPoint = point;
|
|
_moveIntentRun = run;
|
|
_moveIntentRange = range;
|
|
|
|
// A live pursuit renews every think tick; unrenewed intent dies on its own.
|
|
_moveIntentExpire = Core.TickCount + (long)(Mobile.CurrentSpeed * 2000) + 250;
|
|
}
|
|
|
|
public void ClearMoveIntent()
|
|
{
|
|
_moveIntentTarget = null;
|
|
_moveIntentPoint = null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// True while a durable movement goal is live; <paramref name="nextMove"/> is the tick
|
|
/// the movement budget elapses.
|
|
/// </summary>
|
|
public bool TryGetMoveWake(out long nextMove)
|
|
{
|
|
nextMove = NextMove;
|
|
|
|
return (_moveIntentTarget != null || _moveIntentPoint != null) &&
|
|
Core.TickCount - _moveIntentExpire < 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Advances the current pursuit/investigation by one step on a movement-clock wake;
|
|
/// no decisions run.
|
|
/// </summary>
|
|
public void ContinueMove()
|
|
{
|
|
if (!TryGetMoveWake(out var nextMove) || Core.TickCount - nextMove < 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_moveIntentTarget != null)
|
|
{
|
|
ApproachTarget(_moveIntentTarget, _moveIntentRun, _moveIntentRange);
|
|
}
|
|
else
|
|
{
|
|
MoveToPoint(_moveIntentPoint, _moveIntentRun);
|
|
}
|
|
}
|
|
|
|
public virtual bool MoveTo(Mobile m, bool run, int range)
|
|
{
|
|
if (Mobile.Deleted || Mobile.DisallowAllMoves || m?.Deleted != false)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var distance = (int)Mobile.GetDistanceToSqrt(m);
|
|
var distanceThreshold = Core.AOS && IsFollowingMaster() ? 1 : 5;
|
|
|
|
var shouldRun = run && distance > distanceThreshold;
|
|
|
|
if (Mobile.InRange(m, range))
|
|
{
|
|
ResetApproach();
|
|
return true;
|
|
}
|
|
|
|
if (UseGroupMovement(m, range))
|
|
{
|
|
return MoveToWithGroup(this, m, shouldRun, range);
|
|
}
|
|
|
|
return ApproachTarget(m, shouldRun, range);
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public bool IsFollowingMaster() =>
|
|
Mobile.Controlled &&
|
|
Mobile.ControlOrder == OrderType.Follow &&
|
|
Mobile.ControlTarget == Mobile.ControlMaster &&
|
|
Mobile.Combatant == null;
|
|
|
|
private bool MoveToWithCollisionAvoidance(Mobile target, bool run, int range)
|
|
{
|
|
var distance = (int)Mobile.GetDistanceToSqrt(target);
|
|
|
|
var shouldRun = run && distance > 5;
|
|
|
|
var direction = Mobile.GetDirectionTo(target);
|
|
|
|
// Wall-slide auto-turns must not count as progress, or a creature pinned on
|
|
// geometry reports success forever.
|
|
var res = DoMoveImpl(direction, true);
|
|
|
|
if (res is MoveResult.Success or MoveResult.BadState)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
for (var i = 1; i <= 3; i++)
|
|
{
|
|
var clockwise = (Direction)(((int)direction + i) % 8);
|
|
|
|
if (DoMoveImpl(clockwise, true) == MoveResult.Success)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var counterclockwise = (Direction)(((int)direction - i + 8) % 8);
|
|
|
|
if (DoMoveImpl(counterclockwise, true) == MoveResult.Success)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Tactical sidesteps exhausted — route around the obstacle via the centralized
|
|
// approach primitive (persistent PathFollower, no oscillation).
|
|
return ApproachTarget(target, shouldRun, range);
|
|
}
|
|
|
|
public virtual bool WalkMobileRange(Mobile m, int iSteps, bool run, int iWantDistMin, int iWantDistMax)
|
|
{
|
|
if (Mobile.Deleted || Mobile.DisallowAllMoves || m == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
for (var i = 0; i < iSteps; i++)
|
|
{
|
|
var iCurrDist = (int)Mobile.GetDistanceToSqrt(m);
|
|
|
|
var shouldRun = run && iCurrDist > 5;
|
|
|
|
if (iCurrDist >= iWantDistMin && iCurrDist <= iWantDistMax)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (!MoveTowardsOrAwayFrom(m, shouldRun, iCurrDist, iWantDistMax))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
var dist = Mobile.GetDistanceToSqrt(m);
|
|
|
|
return dist >= iWantDistMin && dist <= iWantDistMax;
|
|
}
|
|
|
|
private bool MoveTowardsOrAwayFrom(Mobile m, bool run, int iCurrDist, int iWantDistMax)
|
|
{
|
|
var shouldRun = run && iCurrDist > 5;
|
|
|
|
if (iCurrDist > iWantDistMax)
|
|
{
|
|
// Too far: approach via the centralized progress-based primitive.
|
|
return ApproachTarget(m, shouldRun, iWantDistMax);
|
|
}
|
|
|
|
// Too close: back away. Retreat keeps the simple greedy behavior (out of scope).
|
|
if (DoMove(m.GetDirectionTo(Mobile, shouldRun), true))
|
|
{
|
|
Path = null;
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|