fix: Reverts to RunUO speeds, fixes direction glitching, adds movement speed interpolation (#1695)

> [!IMPORTANT]  
> Please read through these changes, as they changed certain expectations for how the internal AI thinking/movement work.
> Note that some mobs still don't have smooth movement on ClassicUO due to how the client handles animations/movement.

### Summary
- **Important Change**: Mobs will now move at a more regular pace. If the OnThink results in a move, but the cooldown would otherwise prevent the move, then the movement is scheduled on another timer.
- Added a 400ms delay to mobs turning to face a player to attack in order to avoid glitching between moving and turning.
- Reverted AI thinking speeds back to RunUO specific speeds.
- Reverted the AI thinking to moving conversion delays back to RunUO.
- For thinking speeds that are not exactly the predefined speeds from RunUO, there is a new calculation to determine the correct conversion to movement speed. This stops speeds like `0.35` from being faster than `0.3`

> [!NOTE]  
> **Developer Note**
> BaseAI.CheckMove() no longer contains the check for whether or not a mob is on movement cooldown. This function can now be overwritten without causing issues to figuring out that cooldown.
This commit is contained in:
Kamron Batman 2024-03-18 14:13:41 -07:00 • committed by GitHub
parent 9e37879f19
commit 3332fabc39
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 354 additions and 223 deletions

View file

@ -42,6 +42,8 @@ public enum ActionType
public abstract class BaseAI
{
// How many milliseconds until our next move can we consider it ok to move without deferring/blocking.
private const int FuzzyTimeUntilNextMove = 24;
private static readonly SkillName[] m_KeywordTable =
{
SkillName.Parry,
@ -96,7 +98,7 @@ public abstract class BaseAI
protected ActionType m_Action;
public BaseCreature m_Mobile;
public readonly BaseCreature m_Mobile;
private long m_NextDetectHidden;
private long m_NextStopGuard;
@ -869,7 +871,7 @@ public abstract class BaseAI
m_Mobile.Warmode = false;
m_Mobile.Combatant = null;
m_Mobile.FocusMob = null;
m_Mobile.CurrentSpeed = m_Mobile.PassiveSpeed;
m_Mobile.SetCurrentSpeedToPassive();
break;
}
@ -877,7 +879,7 @@ public abstract class BaseAI
{
m_Mobile.Warmode = true;
m_Mobile.FocusMob = null;
m_Mobile.CurrentSpeed = m_Mobile.ActiveSpeed;
m_Mobile.SetCurrentSpeedToActive();
break;
}
@ -886,9 +888,8 @@ public abstract class BaseAI
m_Mobile.Warmode = true;
m_Mobile.FocusMob = null;
m_Mobile.Combatant = null;
m_Mobile.CurrentSpeed = m_Mobile.ActiveSpeed;
m_NextStopGuard = Core.TickCount + (int)TimeSpan.FromSeconds(10).TotalMilliseconds;
m_Mobile.CurrentSpeed = m_Mobile.ActiveSpeed;
m_Mobile.SetCurrentSpeedToActive();
break;
}
@ -896,21 +897,21 @@ public abstract class BaseAI
{
m_Mobile.Warmode = true;
m_Mobile.FocusMob = null;
m_Mobile.CurrentSpeed = m_Mobile.ActiveSpeed;
m_Mobile.SetCurrentSpeedToActive();
break;
}
case ActionType.Interact:
{
m_Mobile.Warmode = false;
m_Mobile.CurrentSpeed = m_Mobile.PassiveSpeed;
m_Mobile.SetCurrentSpeedToPassive();
break;
}
case ActionType.Backoff:
{
m_Mobile.Warmode = false;
m_Mobile.CurrentSpeed = m_Mobile.PassiveSpeed;
m_Mobile.SetCurrentSpeedToPassive();
break;
}
}
@ -968,12 +969,9 @@ public abstract class BaseAI
WalkRandomInHome(2, 2, 1);
}
}
else if (CheckMove())
else if (CheckMove() && CanMoveNow(out _) && !m_Mobile.CheckIdle())
{
if (!m_Mobile.CheckIdle())
{
WalkRandomInHome(2, 2, 1);
}
WalkRandomInHome(2, 2, 1);
}
if (m_Mobile.Combatant?.Deleted == false && m_Mobile.Combatant.Alive &&
@ -1123,7 +1121,7 @@ public abstract class BaseAI
{
m_Mobile.ControlMaster.RevealingAction();
m_Mobile.Home = m_Mobile.Location;
m_Mobile.CurrentSpeed = m_Mobile.PassiveSpeed;
m_Mobile.SetCurrentSpeedToPassive();
m_Mobile.PlaySound(m_Mobile.GetIdleSound());
m_Mobile.Warmode = false;
m_Mobile.Combatant = null;
@ -1133,7 +1131,7 @@ public abstract class BaseAI
case OrderType.Come:
{
m_Mobile.ControlMaster.RevealingAction();
m_Mobile.CurrentSpeed = m_Mobile.ActiveSpeed;
m_Mobile.SetCurrentSpeedToActive();
m_Mobile.PlaySound(m_Mobile.GetIdleSound());
m_Mobile.Warmode = false;
m_Mobile.Combatant = null;
@ -1143,7 +1141,7 @@ public abstract class BaseAI
case OrderType.Drop:
{
m_Mobile.ControlMaster.RevealingAction();
m_Mobile.CurrentSpeed = m_Mobile.PassiveSpeed;
m_Mobile.SetCurrentSpeedToPassive();
m_Mobile.PlaySound(m_Mobile.GetIdleSound());
m_Mobile.Warmode = false;
m_Mobile.Combatant = null;
@ -1160,7 +1158,7 @@ public abstract class BaseAI
case OrderType.Guard:
{
m_Mobile.ControlMaster.RevealingAction();
m_Mobile.CurrentSpeed = m_Mobile.ActiveSpeed;
m_Mobile.SetCurrentSpeedToActive();
m_Mobile.PlaySound(m_Mobile.GetIdleSound());
m_Mobile.Warmode = true;
m_Mobile.Combatant = null;
@ -1171,7 +1169,7 @@ public abstract class BaseAI
case OrderType.Attack:
{
m_Mobile.ControlMaster.RevealingAction();
m_Mobile.CurrentSpeed = m_Mobile.ActiveSpeed;
m_Mobile.SetCurrentSpeedToActive();
m_Mobile.PlaySound(m_Mobile.GetIdleSound());
m_Mobile.Warmode = true;
@ -1182,7 +1180,7 @@ public abstract class BaseAI
case OrderType.Patrol:
{
m_Mobile.ControlMaster.RevealingAction();
m_Mobile.CurrentSpeed = m_Mobile.ActiveSpeed;
m_Mobile.SetCurrentSpeedToActive();
m_Mobile.PlaySound(m_Mobile.GetIdleSound());
m_Mobile.Warmode = false;
m_Mobile.Combatant = null;
@ -1192,7 +1190,7 @@ public abstract class BaseAI
case OrderType.Release:
{
m_Mobile.ControlMaster.RevealingAction();
m_Mobile.CurrentSpeed = m_Mobile.PassiveSpeed;
m_Mobile.SetCurrentSpeedToPassive();
m_Mobile.PlaySound(m_Mobile.GetIdleSound());
m_Mobile.Warmode = false;
m_Mobile.Combatant = null;
@ -1202,7 +1200,7 @@ public abstract class BaseAI
case OrderType.Stay:
{
m_Mobile.ControlMaster.RevealingAction();
m_Mobile.CurrentSpeed = m_Mobile.PassiveSpeed;
m_Mobile.SetCurrentSpeedToPassive();
m_Mobile.PlaySound(m_Mobile.GetIdleSound());
m_Mobile.Warmode = false;
m_Mobile.Combatant = null;
@ -1213,7 +1211,7 @@ public abstract class BaseAI
{
m_Mobile.ControlMaster.RevealingAction();
m_Mobile.Home = m_Mobile.Location;
m_Mobile.CurrentSpeed = m_Mobile.PassiveSpeed;
m_Mobile.SetCurrentSpeedToPassive();
m_Mobile.PlaySound(m_Mobile.GetIdleSound());
m_Mobile.Warmode = false;
m_Mobile.Combatant = null;
@ -1223,7 +1221,7 @@ public abstract class BaseAI
case OrderType.Follow:
{
m_Mobile.ControlMaster.RevealingAction();
m_Mobile.CurrentSpeed = m_Mobile.ActiveSpeed;
m_Mobile.SetCurrentSpeedToActive();
m_Mobile.PlaySound(m_Mobile.GetIdleSound());
m_Mobile.Warmode = false;
@ -1234,7 +1232,7 @@ public abstract class BaseAI
case OrderType.Transfer:
{
m_Mobile.ControlMaster.RevealingAction();
m_Mobile.CurrentSpeed = m_Mobile.PassiveSpeed;
m_Mobile.SetCurrentSpeedToPassive();
m_Mobile.PlaySound(m_Mobile.GetIdleSound());
m_Mobile.Warmode = false;
@ -1988,115 +1986,123 @@ public abstract class BaseAI
return;
}
if (iChanceToNotMove <= 0)
{
return;
}
for (var i = 0; i < iSteps; i++)
{
if (Utility.Random(8 * iChanceToNotMove) <= 8)
if (Utility.Random(1 + iChanceToNotMove) == 0)
{
var iRndMove = Utility.Random(0, 8 + 9 * iChanceToDir);
switch (iRndMove)
{
case 0:
{
DoMove(Direction.Up);
break;
}
case 1:
{
DoMove(Direction.North);
break;
}
case 2:
{
DoMove(Direction.Left);
break;
}
case 3:
{
DoMove(Direction.West);
break;
}
case 5:
{
DoMove(Direction.Down);
break;
}
case 6:
{
DoMove(Direction.South);
break;
}
case 7:
{
DoMove(Direction.Right);
break;
}
case 8:
{
DoMove(Direction.East);
break;
}
default:
{
DoMove(m_Mobile.Direction);
break;
}
}
// iChanceToDir = 2, total weight is 18
// 7/26 chance of other direction
var iRndMove = Utility.Random(8 * (iChanceToDir + 1));
Direction direction = iRndMove < 8 ? (Direction)iRndMove : m_Mobile.Direction;
DoMove(direction);
}
}
}
public double TransformMoveDelay(double thinkingSpeed)
public static double TransformMoveDelay(BaseCreature bc, bool isPassive = false)
{
var isControlled = m_Mobile.Controlled || m_Mobile.Summoned;
// Monster is passive
if (!isControlled && Math.Abs(thinkingSpeed - m_Mobile.PassiveSpeed) < 0.0001)
if (bc == null)
{
thinkingSpeed *= 3; // Monster passive is 3x slower than thinking
}
else if (!isControlled || m_Mobile.ControlOrder != OrderType.Follow || m_Mobile.ControlTarget != m_Mobile.ControlMaster)
{
thinkingSpeed *= 2; // Monster active speed is 2x slower than thinking
return 0.1;
}
if (!m_Mobile.IsDeadPet && (m_Mobile.ReduceSpeedWithDamage || m_Mobile.IsSubdued))
if (bc.MoveSpeedMod > 0)
{
return bc.MoveSpeedMod;
}
var moveSpeed = bc.CurrentSpeed;
var isControlled = bc.Controlled || bc.Summoned;
/*
* Movement Speed Table based on RunUO
* <0.075 -> 0.0375
* 0.075 -> 0.05
* 0.1 -> 0.125
* 0.2 -> 0.3
* 0.25 -> 0.45
* 0.3 -> 0.6
* 0.4 -> 0.85
* 0.5 -> 1.05
* 0.6 -> 1.2,
* 0.8 -> 1.5
* 1.0 -> 1.8
* Above 1.0 is linear
*/
// Linear interpolated
var movementSpeed = moveSpeed switch
{
>= 1.0 => 1.8 * moveSpeed,
>= 0.5 => 1.05 + (moveSpeed - 0.5) * 1.5,
>= 0.4 => 0.85 + (moveSpeed - 0.4) * 2,
>= 0.3 => 0.6 + (moveSpeed - 0.3) * 2.5,
>= 0.2 => 0.3 + (moveSpeed - 0.2) * 3,
>= 0.1 => 0.125 + (moveSpeed - 0.1) * 1.75,
>= 0.075 => 0.05 + (moveSpeed - 0.075) * 3,
_ => 0.0375 // 30 ticks, 37.5ms
};
if (isPassive)
{
movementSpeed += 0.2;
}
if (!isControlled)
{
movementSpeed += 0.1;
}
else if (!bc.Summoned)
{
if (bc.ControlOrder == OrderType.Follow && bc.ControlTarget == bc.ControlMaster)
{
movementSpeed *= 0.5;
}
movementSpeed -= 0.075;
}
if (!bc.IsDeadPet && (bc.ReduceSpeedWithDamage || bc.IsSubdued))
{
int stats, statsMax;
if (Core.HS)
{
stats = m_Mobile.Stam;
statsMax = m_Mobile.StamMax;
stats = bc.Stam;
statsMax = bc.StamMax;
}
else
{
stats = m_Mobile.Hits;
statsMax = m_Mobile.HitsMax;
stats = bc.Hits;
statsMax = bc.HitsMax;
}
var offset = statsMax <= 0 ? 1.0 : Math.Max(0, stats) / (double)statsMax;
if (offset < 1.0)
{
thinkingSpeed += m_Mobile.PassiveSpeed * (1.0 - offset);
}
movementSpeed += (1.0 - offset) * 0.8;
}
return thinkingSpeed;
return movementSpeed;
}
public virtual bool CheckMove() => Core.TickCount - NextMove >= 0;
public bool CanMoveNow(out long delay) => (delay = NextMove - Core.TickCount) <= FuzzyTimeUntilNextMove;
public virtual bool CheckMove() => true;
public virtual bool DoMove(Direction d, bool badStateOk = false)
{
var res = DoMoveImpl(d);
var res = DoMoveImpl(d, badStateOk);
return res is MoveResult.Success or MoveResult.SuccessAutoTurn || badStateOk && res == MoveResult.BadState;
}
public virtual MoveResult DoMoveImpl(Direction d)
public virtual MoveResult DoMoveImpl(Direction d, bool badStateOk)
{
if (m_Mobile.Deleted || m_Mobile.Frozen || m_Mobile.Paralyzed ||
if (m_Mobile == null || m_Mobile.Deleted || m_Mobile.Frozen || m_Mobile.Paralyzed ||
m_Mobile.Spell?.IsCasting == true || m_Mobile.DisallowAllMoves)
{
return MoveResult.BadState;
@ -2107,11 +2113,22 @@ public abstract class BaseAI
return MoveResult.BadState;
}
var delay = (int)(TransformMoveDelay(m_Mobile) * 1000);
if (!CanMoveNow(out var timeUntilMove))
{
// When bad state is ok, we can still move if the time until move is less than the fuzzy time
if (badStateOk)
{
AIMovementTimerPool.GetTimer(TimeSpan.FromMilliseconds(timeUntilMove), this, d).Start();
}
return MoveResult.BadState;
}
// This makes them always move one step, never any direction changes
// TODO: This is firing off deltas which aren't needed. Look into replacing/removing this
m_Mobile.Direction = d;
var delay = (int)(TransformMoveDelay(m_Mobile.CurrentSpeed) * 1000);
NextMove += delay;
if (Core.TickCount - NextMove > 0)
@ -2120,21 +2137,31 @@ public abstract class BaseAI
}
m_Mobile.Pushing = false;
var mobDirection = m_Mobile.Direction;
// Do the actual move
MoveImpl.IgnoreMovableImpassables = m_Mobile.CanMoveOverObstacles && !m_Mobile.CanDestroyObstacles;
var moveResult = m_Mobile.Move(d);
MoveImpl.IgnoreMovableImpassables = false;
if ((m_Mobile.Direction & Direction.Mask) != (d & Direction.Mask))
if (moveResult)
{
var v = m_Mobile.Move(d);
MoveImpl.IgnoreMovableImpassables = false;
return v ? MoveResult.Success : MoveResult.Blocked;
// If we don't delay combat, then a direction change will happen and cause a glitchy sliding effect.
if (m_Mobile.Warmode && m_Mobile.Combatant != null)
{
var remaining = m_Mobile.NextCombatTime - Core.TickCount;
var maxWait = Math.Min(delay, 400);
if (remaining < maxWait)
{
m_Mobile.NextCombatTime = Core.TickCount + maxWait;
}
}
return MoveResult.Success;
}
if (m_Mobile.Move(d))
if ((mobDirection & Direction.Mask) != (d & Direction.Mask))
{
MoveImpl.IgnoreMovableImpassables = false;
return MoveResult.Success;
return MoveResult.Blocked;
}
var wasPushing = m_Mobile.Pushing;
@ -2271,16 +2298,13 @@ public abstract class BaseAI
if (m_Mobile.Move(m_Mobile.Direction))
{
MoveImpl.IgnoreMovableImpassables = false;
return MoveResult.SuccessAutoTurn;
}
}
MoveImpl.IgnoreMovableImpassables = false;
return wasPushing ? MoveResult.BadState : MoveResult.Blocked;
}
MoveImpl.IgnoreMovableImpassables = false;
return MoveResult.Success;
}
@ -2305,7 +2329,7 @@ public abstract class BaseAI
}
else
{
if (region.GoLocation != Point3D.Zero && Utility.Random(10) > 5)
if (region.GoLocation != Point3D.Zero && Utility.RandomBool())
{
DoMove(m_Mobile.GetDirectionTo(region.GoLocation));
}
@ -2336,16 +2360,13 @@ public abstract class BaseAI
{
DoMove(m_Mobile.GetDirectionTo(m_Mobile.Home));
}
else if (Utility.Random(10) > 5)
{
DoMove(m_Mobile.GetDirectionTo(m_Mobile.Home));
}
else
{
if (Utility.Random(10) > 5)
{
DoMove(m_Mobile.GetDirectionTo(m_Mobile.Home));
}
else
{
WalkRandom(iChanceToNotMove, iChanceToDir, 1);
}
WalkRandom(iChanceToNotMove, iChanceToDir, 1);
}
}
else
@ -2447,7 +2468,7 @@ public abstract class BaseAI
* iWantDistMax : The maximum distance we want to be
*
*/
public virtual bool WalkMobileRange(Mobile m, int iSteps, bool bRun, int iWantDistMin, int iWantDistMax)
public virtual bool WalkMobileRange(Mobile m, int iSteps, bool run, int iWantDistMin, int iWantDistMax)
{
if (m_Mobile.Deleted || m_Mobile.DisallowAllMoves)
{
@ -2467,11 +2488,10 @@ public abstract class BaseAI
if (iCurrDist < iWantDistMin || iCurrDist > iWantDistMax)
{
var needCloser = iCurrDist > iWantDistMax;
var needFurther = !needCloser;
if (needCloser && m_Path != null && m_Path.Goal == m)
{
if (m_Path.Follow(bRun, 1))
if (m_Path.Follow(run, 1))
{
m_Path = null;
}
@ -2479,13 +2499,13 @@ public abstract class BaseAI
else
{
var dirTo = iCurrDist > iWantDistMax ?
m_Mobile.GetDirectionTo(m, bRun) : m.GetDirectionTo(m_Mobile, bRun);
m_Mobile.GetDirectionTo(m, run) : m.GetDirectionTo(m_Mobile, run);
if (!DoMove(dirTo, true) && needCloser)
{
m_Path = new PathFollower(m_Mobile, m) { Mover = DoMoveImpl };
if (m_Path.Follow(bRun, 1))
if (m_Path.Follow(run, 1))
{
m_Path = null;
}
@ -2882,9 +2902,6 @@ public abstract class BaseAI
}
}
/*
* The mobile changed speeds, we must adjust the timer
*/
public virtual void OnCurrentSpeedChanged()
{
m_Timer.Interval = TimeSpan.FromSeconds(Math.Max(0.008, m_Mobile.CurrentSpeed));
@ -3247,4 +3264,64 @@ public abstract class BaseAI
}
}
}
public static class AIMovementTimerPool
{
private const int _poolSize = 1024;
private static readonly Queue<AIMovementTimer> _pool = new (_poolSize);
public static void Configure()
{
var i = 0;
while (i++ < _poolSize)
{
_pool.Enqueue(new AIMovementTimer());
}
}
public static AIMovementTimer GetTimer(TimeSpan delay, BaseAI ai, Direction direction)
{
AIMovementTimer timer;
if (_pool.Count > 0)
{
timer = _pool.Dequeue();
}
else
{
timer = new AIMovementTimer();
}
timer.Set(delay, ai, direction);
return timer;
}
public class AIMovementTimer : Timer
{
public BaseAI AI { get; private set; }
public Direction Direction { get; private set; }
public AIMovementTimer() : base(TimeSpan.Zero)
{
}
public void Set(TimeSpan delay, BaseAI ai, Direction direction)
{
if (Running)
{
return;
}
Delay = delay;
AI = ai;
Direction = direction;
}
protected override void OnTick()
{
AI?.DoMove(Direction);
AI = null;
_pool.Enqueue(this);
}
}
}
}