feat: derive the Running bit from the step pace and fix step-pacing bursts (#2599)
Stacked on #2594. Fixes jerky creature movement (lich / Fast-bucket melee chases) by choosing the client animation flag from the actual step pace instead of a caller-supplied `run` argument, and fixes three step-pacing defects in the move budget found while verifying it with paired server/client traces. ### Why The `Direction.Running` bit does nothing for creatures server-side (`Mobile.OnMove` reads it only for the player throttle and stealth reveal). Its whole effect is on the client, which animates each step over a fixed time selected by that bit: walk 400 ms / run 200 ms on foot, 200 / 100 ms mounted. ClassicUO queues up to 5 steps and *drops* the sixth, so a creature stepping every 300 ms while flagged as walking backs the queue up until it snaps forward — the observed jerk. The `run` argument never carried the one fact that matters (the step interval). RunUO passed `true` in combat / `false` for pets and gated it on `dist > 5`; #2271 flipped every combat site to `false`; pets passed `currentDistance > 2`. None of that is a coherent signal. ### What **Pace-derived run flag** - `BaseAI.ShouldRun()`: run iff the effective step delay (move clock + badly-hurt inflation) is shorter than `Movement.WalkFootDelay` / `WalkMountDelay` (mounted or flying) — with a continuity rule: an *isolated* step (taken after standing at least a walk interval) goes out as a walk, because the client renders each step alone and a lone run-flagged step is a 200 ms dart. Only a continuing cadence flags run; a true sprinter (pace under the run interpolation) always runs, since a walk-rendered first step would flood the client's 5-step queue. This reproduces RunUO's close-in feel (its `dist > 5` gate) from first principles. - `DoMoveImpl` stamps the bit; it is the single place the flag is set. - `run` removed from `MoveTo`, `WalkMobileRange`, `ApproachTarget`, `MoveToPoint`, `MoveToWithGroup`, `MoveToWithCollisionAvoidance`, the move intent, and `PathFollower.Follow`. All 35 call sites updated. **API change** for custom scripts — documented in the RunUO migration docs (`09-items-mobiles-creatures.md`, `11-api-reference.md`) and `content-patterns.md` § Creature Speeds. **Move-budget pacing fixes** (each confirmed by UTC-aligned server/client step traces) - A stall no longer banks catch-up steps: the budget's snap-to-now released up to three steps in ~300 ms when a creature resumed chasing after standing beside its target — rendered as a teleport. - Debt accrual removed entirely: a step landing sub-period late (think-grid vs budget misalignment during reactive mirroring) kept the remainder and fired a follow-up ~100 ms later — a dart pair. `ConsumeMoveBudget` now paces every step from when it was actually taken; in continuous pursuit the move-wake lands within wheel resolution of the deadline, so the cost is single-digit-ms drift. - Net effect: a creature can never step faster than its pace, verified across a full chase session (zero sub-pace steps; metronomic 350 ms cadence for a 0.3 s lich). - Test fixture now runs `Movement.Configure()` (the walk delays were 0 in tests). ### Accepted trade-off Animal (LOW group) bodies without a run animation slide on their stand frames when flagged as running. Most are slow enough to stay flagged as walking; the client-side fallback is in ClassicUO/ClassicUO#1930. ### Tests `RunFlagTests`: foot thresholds (0.3 / 0.125 run; 0.4 / 0.45 / 1.05 walk), flying uses the mount threshold, badly-hurt inflation flips a 0.35 s creature back to walk, a real `DoMove` stamps the bit, isolated steps drop to walk (sprinters keep running), a stall restarts the cadence with no banked steps, and a late step earns no quicker follow-up. Full suite: 837 Server + 747 UOContent green.
This commit is contained in:
parent
4420872b22
commit
e07416902a
26 changed files with 294 additions and 84 deletions
|
|
@ -103,6 +103,7 @@ internal static class TestServerInitializer
|
|||
// Registers the Accounts entity persistence; without it no test can construct an Account.
|
||||
Server.Accounting.Accounts.Configure();
|
||||
RaceDefinitions.Configure();
|
||||
Server.Movement.Movement.Configure();
|
||||
MovementImpl.Configure();
|
||||
PathFollower.Configure();
|
||||
World.Load();
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ public class ApproachTargetTests
|
|||
for (var i = 0; i < maxTicks; i++)
|
||||
{
|
||||
ai.NextMove = 0;
|
||||
ai.WalkMobileRange(target, 1, false, 1, 2);
|
||||
ai.WalkMobileRange(target, 1, 1, 2);
|
||||
if (bc.InRange(target, arriveDist))
|
||||
{
|
||||
return true;
|
||||
|
|
@ -123,7 +123,7 @@ public class ApproachTargetTests
|
|||
for (var i = 0; i < 200; i++)
|
||||
{
|
||||
ai.NextMove = 0;
|
||||
ai.MoveTo(target, false, 1);
|
||||
ai.MoveTo(target, 1);
|
||||
if (bc.InRange(target, 1))
|
||||
{
|
||||
arrived = true;
|
||||
|
|
@ -154,7 +154,7 @@ public class ApproachTargetTests
|
|||
for (var i = 0; i < 60; i++)
|
||||
{
|
||||
ai.NextMove = 0;
|
||||
ai.MoveTo(target, true, 1);
|
||||
ai.MoveTo(target, 1);
|
||||
|
||||
// Target walks west every other tick for its first several steps, then stops,
|
||||
// so a same-speed chaser eventually closes the gap.
|
||||
|
|
@ -214,7 +214,7 @@ public class ApproachTargetTests
|
|||
for (var i = 0; i < 120; i++)
|
||||
{
|
||||
ai.NextMove = 0;
|
||||
ai.MoveTo(target, false, 1);
|
||||
ai.MoveTo(target, 1);
|
||||
}
|
||||
|
||||
// After giving up, the creature must idle (not oscillate) while the goal is still.
|
||||
|
|
@ -223,7 +223,7 @@ public class ApproachTargetTests
|
|||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
ai.NextMove = 0;
|
||||
ai.MoveTo(target, false, 1);
|
||||
ai.MoveTo(target, 1);
|
||||
if (bc.Location != idleStart)
|
||||
{
|
||||
stayedIdle = false;
|
||||
|
|
|
|||
162
Projects/UOContent.Tests/Tests/Mobiles/AI/RunFlagTests.cs
Normal file
162
Projects/UOContent.Tests/Tests/Mobiles/AI/RunFlagTests.cs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
using System.Collections.Generic;
|
||||
using Server;
|
||||
using Server.Mobiles;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests.Mobiles.AI;
|
||||
|
||||
// The Running bit is derived from the step pace: a step shorter than the client's walk
|
||||
// interpolation (400ms on foot, 200ms mounted/flying) is flagged as a run.
|
||||
[Collection("Sequential Pathfinding Tests")]
|
||||
public class RunFlagTests : System.IDisposable
|
||||
{
|
||||
private readonly List<Mobile> _created = new();
|
||||
|
||||
private PetTestStub Spawn(double activeMove)
|
||||
{
|
||||
var pet = new PetTestStub();
|
||||
pet.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca);
|
||||
pet.AIObject.AITimer?.Stop();
|
||||
pet.SetMoveSpeed(activeMove, activeMove * 3);
|
||||
pet.SetCurrentSpeedToActive();
|
||||
pet.LastMoveTime = Core.TickCount; // mid-cadence unless a test says otherwise
|
||||
_created.Add(pet);
|
||||
return pet;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var m in _created)
|
||||
{
|
||||
m?.Delete();
|
||||
}
|
||||
|
||||
_created.Clear();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.3, true)]
|
||||
[InlineData(0.125, true)]
|
||||
[InlineData(0.4, false)]
|
||||
[InlineData(0.45, false)]
|
||||
[InlineData(1.05, false)]
|
||||
public void FootCreature_RunsOnlyWhenFasterThanWalk(double activeMove, bool expected)
|
||||
{
|
||||
var pet = Spawn(activeMove);
|
||||
|
||||
Assert.Equal(activeMove, pet.CurrentMoveSpeed);
|
||||
Assert.Equal(expected, pet.AIObject.ShouldRun());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.3, false)]
|
||||
[InlineData(0.15, true)]
|
||||
public void FlyingCreature_UsesMountThresholds(double activeMove, bool expected)
|
||||
{
|
||||
var pet = Spawn(activeMove);
|
||||
pet.Flying = true;
|
||||
|
||||
Assert.Equal(expected, pet.AIObject.ShouldRun());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BadlyHurt_SlowsBelowWalk_DropsToWalk()
|
||||
{
|
||||
var pet = Spawn(0.35);
|
||||
Assert.True(pet.AIObject.ShouldRun());
|
||||
|
||||
// The hurt inflation is on the observed step pace, so the flag follows it.
|
||||
pet.SetHits(100);
|
||||
pet.Hits = 5;
|
||||
pet.SetStam(100);
|
||||
pet.Stam = 5;
|
||||
|
||||
Assert.False(pet.AIObject.ShouldRun());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.3, true)]
|
||||
[InlineData(0.45, false)]
|
||||
public void DoMove_StampsRunningBit(double activeMove, bool expected)
|
||||
{
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||
|
||||
var pet = Spawn(activeMove);
|
||||
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
|
||||
|
||||
var ai = pet.AIObject;
|
||||
ai.NextMove = 0;
|
||||
var start = pet.Location;
|
||||
|
||||
Assert.True(ai.DoMove(Direction.West));
|
||||
Assert.NotEqual(start, pet.Location);
|
||||
Assert.Equal(expected, (pet.Direction & Direction.Running) != 0);
|
||||
}
|
||||
|
||||
// An isolated step (after standing at least a walk interval) renders alone and darts
|
||||
// if run-flagged, so it walks; continuing cadences and true sprinters keep the flag.
|
||||
[Fact]
|
||||
public void IsolatedStep_DropsToWalk()
|
||||
{
|
||||
var pet = Spawn(0.3);
|
||||
pet.LastMoveTime = Core.TickCount - 1000;
|
||||
|
||||
Assert.False(pet.AIObject.ShouldRun());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsolatedStep_SprinterStillRuns()
|
||||
{
|
||||
var pet = Spawn(0.125);
|
||||
pet.LastMoveTime = Core.TickCount - 1000;
|
||||
|
||||
Assert.True(pet.AIObject.ShouldRun());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StallDoesNotBankCatchUpSteps()
|
||||
{
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||
|
||||
var pet = Spawn(0.3);
|
||||
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
|
||||
|
||||
var ai = pet.AIObject;
|
||||
ai.NextMove = Core.TickCount - 1000;
|
||||
|
||||
Assert.True(ai.DoMove(Direction.West));
|
||||
|
||||
// A stall must restart the cadence at full pace: banked catch-up steps
|
||||
// release as a burst the client renders as a sprint/teleport.
|
||||
Assert.False(ai.CanMoveNow(out _));
|
||||
Assert.True(ai.NextMove - Core.TickCount > 250);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LateStepDoesNotEarnAQuickerFollowUp()
|
||||
{
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||
|
||||
var pet = Spawn(0.3);
|
||||
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
|
||||
|
||||
pet.Warmode = true; // keep the active move clock through the step
|
||||
|
||||
var ai = pet.AIObject;
|
||||
// The step lands 200ms past the budget — under one period, the reactive
|
||||
// mirroring case (think grid vs budget deadline misalignment).
|
||||
ai.NextMove = Core.TickCount - 200;
|
||||
|
||||
Assert.True(ai.DoMove(Direction.West));
|
||||
|
||||
// The debt must not be repaid: a sub-period catch-up step follows ~100ms
|
||||
// behind and renders as a dart pair beside the player.
|
||||
Assert.True(ai.NextMove - Core.TickCount > 250);
|
||||
}
|
||||
}
|
||||
|
|
@ -359,14 +359,14 @@ namespace Server.Factions
|
|||
{
|
||||
if (m_Mobile.InRange( m, 1 ))
|
||||
RunFrom( m );
|
||||
else if (!m_Mobile.InRange( m, m_Mobile.RangeFight > 2 ? m_Mobile.RangeFight : 2 ) && !MoveTo( m, true, 1 ))
|
||||
else if (!m_Mobile.InRange( m, m_Mobile.RangeFight > 2 ? m_Mobile.RangeFight : 2 ) && !MoveTo(m, 1))
|
||||
OnFailedMove();
|
||||
}
|
||||
else
|
||||
{*/
|
||||
if (!Mobile.InRange(m, Mobile.RangeFight))
|
||||
{
|
||||
if (!MoveTo(m, true, 1))
|
||||
if (!MoveTo(m, 1))
|
||||
{
|
||||
OnFailedMove();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ public class PathFollower
|
|||
public static bool Check(Point3D loc, Point3D goal, int range) =>
|
||||
Utility.InRange(loc, goal, range) && (range > 1 || (loc.Z - goal.Z).Abs() < 16);
|
||||
|
||||
public bool Follow(bool run, int range)
|
||||
public bool Follow(int range)
|
||||
{
|
||||
var goal = GetGoalLocation();
|
||||
Direction d;
|
||||
|
|
@ -97,13 +97,13 @@ public class PathFollower
|
|||
|
||||
if (!(Enabled && m_Path.Success))
|
||||
{
|
||||
d = m_From.GetDirectionTo(goal, run);
|
||||
d = m_From.GetDirectionTo(goal);
|
||||
m_From.SetDirection(d);
|
||||
|
||||
return Move(d) is MoveResult.Success or MoveResult.SuccessAutoTurn && Check(m_From.Location, goal, range);
|
||||
}
|
||||
|
||||
d = m_From.GetDirectionTo(m_Next, run);
|
||||
d = m_From.GetDirectionTo(m_Next);
|
||||
m_From.SetDirection(d);
|
||||
var res = Move(d);
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ public class AnimalAI : BaseAI
|
|||
return true;
|
||||
}
|
||||
|
||||
if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight))
|
||||
if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight))
|
||||
{
|
||||
if (Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ public class ArcherAI : BaseAI
|
|||
return true;
|
||||
}
|
||||
|
||||
if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.Weapon.MaxRange))
|
||||
if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.Weapon.MaxRange))
|
||||
{
|
||||
this.DebugSayFormatted($"I am still not in range of {combatant.Name}");
|
||||
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ public abstract partial class BaseAI
|
|||
return crowding;
|
||||
}
|
||||
|
||||
public static bool MoveToWithGroup(BaseAI ai, Mobile target, bool run, int range)
|
||||
public static bool MoveToWithGroup(BaseAI ai, Mobile target, int range)
|
||||
{
|
||||
if (Core.TickCount - _lastGroupUpdateTime > 1000)
|
||||
{
|
||||
|
|
@ -79,7 +79,7 @@ public abstract partial class BaseAI
|
|||
if (optimalPosition == Point3D.Zero)
|
||||
{
|
||||
|
||||
return ai.MoveToWithCollisionAvoidance(target, run, range);
|
||||
return ai.MoveToWithCollisionAvoidance(target, range);
|
||||
}
|
||||
|
||||
_reservedPositions[mobile] = optimalPosition;
|
||||
|
|
@ -99,7 +99,7 @@ public abstract partial class BaseAI
|
|||
}
|
||||
|
||||
// A blocked or wall-slid step is not progress — route around the obstacle.
|
||||
return ai.ApproachTarget(target, run, range);
|
||||
return ai.ApproachTarget(target, range);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ using System.Runtime.CompilerServices;
|
|||
using Server.Collections;
|
||||
using Server.Items;
|
||||
using MoveImpl = Server.Movement.MovementImpl;
|
||||
using Moves = Server.Movement.Movement;
|
||||
|
||||
namespace Server.Mobiles;
|
||||
|
||||
|
|
@ -43,7 +44,6 @@ public abstract partial class BaseAI
|
|||
// 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;
|
||||
|
||||
|
|
@ -74,23 +74,42 @@ public abstract partial class BaseAI
|
|||
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()
|
||||
// Seconds per step as the client observes it: the move clock plus the hurt inflation.
|
||||
private double EffectiveStepDelay()
|
||||
{
|
||||
var stepDelay = Mobile.CurrentMoveSpeed;
|
||||
|
||||
if (!(Core.AOS && IsFollowingMaster()))
|
||||
return Core.AOS && IsFollowingMaster() ? stepDelay : BadlyHurtMoveDelay(Mobile, stepDelay);
|
||||
}
|
||||
|
||||
// The Running bit only selects the client's per-step interpolation (walk 400ms / run
|
||||
// 200ms on foot, 200/100 mounted). A step shorter than the walk time must run or the
|
||||
// client falls behind and snaps — but an isolated step (after standing at least a walk
|
||||
// interval) renders alone and darts if run-flagged, so it goes out as a walk. A true
|
||||
// sprinter always runs: a walk-rendered first step would flood the client's queue.
|
||||
public bool ShouldRun()
|
||||
{
|
||||
var mounted = Mobile.Mounted || Mobile.Flying;
|
||||
var walkDelay = mounted ? Moves.WalkMountDelay : Moves.WalkFootDelay;
|
||||
var pace = EffectiveStepDelay() * 1000;
|
||||
|
||||
if (pace >= walkDelay)
|
||||
{
|
||||
stepDelay = BadlyHurtMoveDelay(Mobile, stepDelay);
|
||||
return false;
|
||||
}
|
||||
|
||||
NextMove += Math.Max(50, (long)(stepDelay * 1000));
|
||||
var runDelay = mounted ? Moves.RunMountDelay : Moves.RunFootDelay;
|
||||
|
||||
if (Core.TickCount - NextMove > 0)
|
||||
{
|
||||
NextMove = Core.TickCount;
|
||||
}
|
||||
return pace < runDelay || Core.TickCount - Mobile.LastMoveTime < walkDelay;
|
||||
}
|
||||
|
||||
// One step per period, paced from the step just taken — no debt accrual: repaying a
|
||||
// late step with a quicker follow-up puts two steps ~100ms apart, which renders as a
|
||||
// dart. In continuous pursuit the move-wake lands within wheel resolution of this
|
||||
// deadline, so the only cost is single-digit-ms drift per step.
|
||||
private void ConsumeMoveBudget()
|
||||
{
|
||||
NextMove = Core.TickCount + Math.Max(50, (long)(EffectiveStepDelay() * 1000));
|
||||
}
|
||||
|
||||
public virtual bool CheckMove() => !(Mobile.Deleted || Mobile.DisallowAllMoves);
|
||||
|
|
@ -108,6 +127,8 @@ public abstract partial class BaseAI
|
|||
return MoveResult.BadState;
|
||||
}
|
||||
|
||||
d = (d & Direction.Mask) | (ShouldRun() ? Direction.Running : 0);
|
||||
|
||||
if ((Mobile.Direction & Direction.Mask) != (d & Direction.Mask))
|
||||
{
|
||||
Mobile.Direction = d;
|
||||
|
|
@ -334,7 +355,7 @@ public abstract partial class BaseAI
|
|||
/// 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)
|
||||
protected bool ApproachTarget(Mobile target, int range)
|
||||
{
|
||||
if (Mobile.Deleted || Mobile.DisallowAllMoves || target?.Deleted != false)
|
||||
{
|
||||
|
|
@ -361,7 +382,7 @@ public abstract partial class BaseAI
|
|||
ResetApproach(); // target moved — try again fresh
|
||||
}
|
||||
|
||||
RenewMoveIntent(target, null, run, range);
|
||||
RenewMoveIntent(target, null, 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
|
||||
|
|
@ -373,7 +394,7 @@ public abstract partial class BaseAI
|
|||
if (Path == null && Mobile.InLOS(target))
|
||||
{
|
||||
var distBefore = Mobile.GetDistanceToSqrt(target);
|
||||
var res = DoMoveImpl(Mobile.GetDirectionTo(target, run), true);
|
||||
var res = DoMoveImpl(Mobile.GetDirectionTo(target), true);
|
||||
|
||||
if (res == MoveResult.BadState)
|
||||
{
|
||||
|
|
@ -402,7 +423,7 @@ public abstract partial class BaseAI
|
|||
var couldMove = CanMoveNow(out _) && !IsInBadState();
|
||||
var locBefore = Mobile.Location;
|
||||
|
||||
if (Path.Follow(run, range))
|
||||
if (Path.Follow(range))
|
||||
{
|
||||
ResetApproach();
|
||||
return true;
|
||||
|
|
@ -421,7 +442,7 @@ public abstract partial class BaseAI
|
|||
/// 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)
|
||||
public bool MoveToPoint(IPoint3D goal)
|
||||
{
|
||||
if (Mobile.Deleted || Mobile.DisallowAllMoves || goal == null)
|
||||
{
|
||||
|
|
@ -434,12 +455,12 @@ public abstract partial class BaseAI
|
|||
Path = new PathFollower(Mobile, goal) { Mover = DoMoveImpl };
|
||||
}
|
||||
|
||||
RenewMoveIntent(null, goal, run, 1);
|
||||
RenewMoveIntent(null, goal, 1);
|
||||
|
||||
var couldMove = CanMoveNow(out _) && !IsInBadState();
|
||||
var locBefore = Mobile.Location;
|
||||
|
||||
if (Path.Follow(run, 1))
|
||||
if (Path.Follow(1))
|
||||
{
|
||||
Path = null;
|
||||
ClearMoveIntent();
|
||||
|
|
@ -515,11 +536,10 @@ public abstract partial class BaseAI
|
|||
_approachGaveUp = false;
|
||||
}
|
||||
|
||||
private void RenewMoveIntent(Mobile target, IPoint3D point, bool run, int range)
|
||||
private void RenewMoveIntent(Mobile target, IPoint3D point, int range)
|
||||
{
|
||||
_moveIntentTarget = target;
|
||||
_moveIntentPoint = point;
|
||||
_moveIntentRun = run;
|
||||
_moveIntentRange = range;
|
||||
|
||||
// A live pursuit renews every think tick; unrenewed intent dies on its own.
|
||||
|
|
@ -556,26 +576,21 @@ public abstract partial class BaseAI
|
|||
|
||||
if (_moveIntentTarget != null)
|
||||
{
|
||||
ApproachTarget(_moveIntentTarget, _moveIntentRun, _moveIntentRange);
|
||||
ApproachTarget(_moveIntentTarget, _moveIntentRange);
|
||||
}
|
||||
else
|
||||
{
|
||||
MoveToPoint(_moveIntentPoint, _moveIntentRun);
|
||||
MoveToPoint(_moveIntentPoint);
|
||||
}
|
||||
}
|
||||
|
||||
public virtual bool MoveTo(Mobile m, bool run, int range)
|
||||
public virtual bool MoveTo(Mobile m, int range)
|
||||
{
|
||||
if (Mobile.Deleted || Mobile.DisallowAllMoves || m?.Deleted != false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var distance = (int)Mobile.GetDistanceToSqrt(m);
|
||||
//TODO Derive the Running bit from CurrentMoveSpeed in DoMoveImpl and drop the run parameter
|
||||
var distanceThreshold = Core.AOS && IsFollowingMaster() ? 1 : 3;
|
||||
var shouldRun = distance > distanceThreshold;
|
||||
|
||||
if (Mobile.InRange(m, range))
|
||||
{
|
||||
ResetApproach();
|
||||
|
|
@ -584,10 +599,10 @@ public abstract partial class BaseAI
|
|||
|
||||
if (UseGroupMovement(m, range))
|
||||
{
|
||||
return MoveToWithGroup(this, m, shouldRun, range);
|
||||
return MoveToWithGroup(this, m, range);
|
||||
}
|
||||
|
||||
return ApproachTarget(m, shouldRun, range);
|
||||
return ApproachTarget(m, range);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
|
|
@ -604,12 +619,8 @@ public abstract partial class BaseAI
|
|||
Mobile.Combatant == null &&
|
||||
Mobile.ControlOrder is OrderType.Come or OrderType.Follow or OrderType.Guard;
|
||||
|
||||
private bool MoveToWithCollisionAvoidance(Mobile target, bool run, int range)
|
||||
private bool MoveToWithCollisionAvoidance(Mobile target, 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
|
||||
|
|
@ -640,10 +651,10 @@ public abstract partial class BaseAI
|
|||
|
||||
// Tactical sidesteps exhausted — route around the obstacle via the centralized
|
||||
// approach primitive (persistent PathFollower, no oscillation).
|
||||
return ApproachTarget(target, shouldRun, range);
|
||||
return ApproachTarget(target, range);
|
||||
}
|
||||
|
||||
public virtual bool WalkMobileRange(Mobile m, int iSteps, bool run, int iWantDistMin, int iWantDistMax)
|
||||
public virtual bool WalkMobileRange(Mobile m, int iSteps, int iWantDistMin, int iWantDistMax)
|
||||
{
|
||||
if (Mobile.Deleted || Mobile.DisallowAllMoves || m == null)
|
||||
{
|
||||
|
|
@ -659,7 +670,7 @@ public abstract partial class BaseAI
|
|||
return true;
|
||||
}
|
||||
|
||||
if (!MoveTowardsOrAwayFrom(m, run, iCurrDist, iWantDistMax))
|
||||
if (!MoveTowardsOrAwayFrom(m, iCurrDist, iWantDistMax))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
|
@ -670,17 +681,16 @@ public abstract partial class BaseAI
|
|||
return dist >= iWantDistMin && dist <= iWantDistMax;
|
||||
}
|
||||
|
||||
// run only sets the client animation; callers gate it on their own distance thresholds.
|
||||
private bool MoveTowardsOrAwayFrom(Mobile m, bool run, int iCurrDist, int iWantDistMax)
|
||||
private bool MoveTowardsOrAwayFrom(Mobile m, int iCurrDist, int iWantDistMax)
|
||||
{
|
||||
if (iCurrDist > iWantDistMax)
|
||||
{
|
||||
// Too far: approach via the centralized progress-based primitive.
|
||||
return ApproachTarget(m, run, iWantDistMax);
|
||||
return ApproachTarget(m, iWantDistMax);
|
||||
}
|
||||
|
||||
// Too close: back away. Retreat keeps the simple greedy behavior (out of scope).
|
||||
if (DoMove(m.GetDirectionTo(Mobile, run), true))
|
||||
if (DoMove(m.GetDirectionTo(Mobile), true))
|
||||
{
|
||||
Path = null;
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -442,7 +442,7 @@ public abstract partial class BaseAI
|
|||
var master = Mobile.SummonMaster;
|
||||
if (master != null && master.Map == Mobile.Map && master.InRange(Mobile, Mobile.RangePerception))
|
||||
{
|
||||
MoveTo(master, false, 1);
|
||||
MoveTo(master, 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -592,7 +592,7 @@ public abstract partial class BaseAI
|
|||
}
|
||||
|
||||
_lkpGoal ??= _lkpLocation;
|
||||
return MoveToPoint(_lkpGoal, false);
|
||||
return MoveToPoint(_lkpGoal);
|
||||
}
|
||||
|
||||
private void ClearLastKnown()
|
||||
|
|
@ -644,7 +644,7 @@ public abstract partial class BaseAI
|
|||
_herdGoal = new Point3D(target.X, target.Y, Mobile.Map?.GetAverageZ(target.X, target.Y) ?? Mobile.Z);
|
||||
}
|
||||
|
||||
MoveToPoint(_herdGoal, false);
|
||||
MoveToPoint(_herdGoal);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -798,7 +798,7 @@ public abstract partial class BaseAI
|
|||
{
|
||||
if (AcquireFocusMob(Mobile.RangePerception * 2, FightMode.Closest, true, false, true))
|
||||
{
|
||||
if (WalkMobileRange(Mobile.FocusMob, 1, false, Mobile.RangePerception, Mobile.RangePerception * 2))
|
||||
if (WalkMobileRange(Mobile.FocusMob, 1, Mobile.RangePerception, Mobile.RangePerception * 2))
|
||||
{
|
||||
DebugSay("I backed off to safety. Wandering...");
|
||||
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ public abstract partial class BaseAI
|
|||
return true;
|
||||
}
|
||||
|
||||
WalkMobileRange(Mobile.ControlMaster, 1, false, 1, 2);
|
||||
WalkMobileRange(Mobile.ControlMaster, 1, 1, 2);
|
||||
|
||||
if (Mobile.GetDistanceToSqrt(Mobile.ControlMaster) <= 2)
|
||||
{
|
||||
|
|
@ -136,7 +136,7 @@ public abstract partial class BaseAI
|
|||
|
||||
if (currentDistance > 1)
|
||||
{
|
||||
WalkMobileRange(Mobile.ControlTarget, 1, currentDistance > 2, 1, 2);
|
||||
WalkMobileRange(Mobile.ControlTarget, 1, 1, 2);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -333,7 +333,7 @@ public abstract partial class BaseAI
|
|||
Mobile.SetCurrentSpeedToActive();
|
||||
}
|
||||
|
||||
WalkMobileRange(controlMaster, 1, true, 1, 3);
|
||||
WalkMobileRange(controlMaster, 1, 1, 3);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ public class BerserkAI : BaseAI
|
|||
return true;
|
||||
}
|
||||
|
||||
if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight))
|
||||
if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight))
|
||||
{
|
||||
this.DebugSayFormatted($"I am still not in range of {combatant.Name}");
|
||||
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ public class HealerAI : BaseAI
|
|||
return true;
|
||||
}
|
||||
|
||||
WalkMobileRange(Mobile.FocusMob, 1, false, 4, 7);
|
||||
WalkMobileRange(Mobile.FocusMob, 1, 4, 7);
|
||||
|
||||
// TODO: Should it be able to do this?
|
||||
if (Mobile.TriggerAbility(MonsterAbilityTrigger.CombatAction, Mobile.Combatant))
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ public class MageAI : BaseAI
|
|||
{
|
||||
if (!SmartAI)
|
||||
{
|
||||
if (!MoveTo(m, false, Mobile.RangeFight))
|
||||
if (!MoveTo(m, Mobile.RangeFight))
|
||||
{
|
||||
OnFailedMove();
|
||||
}
|
||||
|
|
@ -185,14 +185,14 @@ public class MageAI : BaseAI
|
|||
{
|
||||
RunFrom(m);
|
||||
}
|
||||
else if (!Mobile.InRange(m, Math.Max(Mobile.RangeFight, 2)) && !MoveTo(m, false, 1))
|
||||
else if (!Mobile.InRange(m, Math.Max(Mobile.RangeFight, 2)) && !MoveTo(m, 1))
|
||||
{
|
||||
OnFailedMove();
|
||||
}
|
||||
}
|
||||
else if (!Mobile.InRange(m, Mobile.RangeFight))
|
||||
{
|
||||
if (!MoveTo(m, false, 1))
|
||||
if (!MoveTo(m, 1))
|
||||
{
|
||||
OnFailedMove();
|
||||
}
|
||||
|
|
@ -701,7 +701,7 @@ public class MageAI : BaseAI
|
|||
{
|
||||
DebugSay("I cannot see my target, moving to regain line of sight");
|
||||
|
||||
if (!MoveTo(c, false, 1))
|
||||
if (!MoveTo(c, 1))
|
||||
{
|
||||
OnFailedMove();
|
||||
}
|
||||
|
|
@ -1039,7 +1039,7 @@ public class MageAI : BaseAI
|
|||
// target can be invoked.
|
||||
if (!Mobile.InLOS(toTarget))
|
||||
{
|
||||
MoveTo(toTarget, true, 1);
|
||||
MoveTo(toTarget, 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ public class MeleeAI : BaseAI
|
|||
|
||||
private bool AttemptMoveToCombatant(Mobile combatant)
|
||||
{
|
||||
if (MoveTo(combatant, false, Mobile.RangeFight))
|
||||
if (MoveTo(combatant, Mobile.RangeFight))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ public class PredatorAI : BaseAI
|
|||
return true;
|
||||
}
|
||||
|
||||
if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight))
|
||||
if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight))
|
||||
{
|
||||
if (Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1)
|
||||
{
|
||||
|
|
@ -70,7 +70,7 @@ public class PredatorAI : BaseAI
|
|||
}
|
||||
else if (AcquireFocusMob(Mobile.RangePerception * 2, FightMode.Closest, true, false, true))
|
||||
{
|
||||
if (WalkMobileRange(Mobile.FocusMob, 1, false, Mobile.RangePerception, Mobile.RangePerception * 2))
|
||||
if (WalkMobileRange(Mobile.FocusMob, 1, Mobile.RangePerception, Mobile.RangePerception * 2))
|
||||
{
|
||||
DebugSay("Well, here I am safe");
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ public class ThiefAI : BaseAI
|
|||
return true;
|
||||
}
|
||||
|
||||
if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight))
|
||||
if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight))
|
||||
{
|
||||
this.DebugSayFormatted($"I should be closer to {combatant.Name}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2861,7 +2861,7 @@ namespace Server.Mobiles
|
|||
CanBeHarmful(m) && IsEnemy(m))
|
||||
{
|
||||
Combatant = FocusMob = m;
|
||||
AIObject?.MoveTo(m, true, 1);
|
||||
AIObject?.MoveTo(m, 1);
|
||||
DoHarmful(m);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ public abstract partial class BaseFamiliar : BaseCreature
|
|||
Hidden = m_LastHidden = master.Hidden;
|
||||
}
|
||||
|
||||
if (AIObject?.WalkMobileRange(master, 5, false, 1, 1) == true)
|
||||
if (AIObject?.WalkMobileRange(master, 5, 1, 1) == true)
|
||||
{
|
||||
Warmode = master.Warmode;
|
||||
Combatant = master.Combatant;
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ namespace Server.Mobiles
|
|||
*/
|
||||
else if (!Combat(this))
|
||||
{
|
||||
AIObject?.MoveTo(SummonMaster, false, 5);
|
||||
AIObject?.MoveTo(SummonMaster, 5);
|
||||
}
|
||||
/*
|
||||
On OSI, if the summon attacks a mobile, the summoner meer also
|
||||
|
|
|
|||
|
|
@ -238,10 +238,7 @@ namespace Server.Mobiles
|
|||
|
||||
if (master?.Map == Mobile.Map && master?.InRange(Mobile, Mobile.RangePerception) == true)
|
||||
{
|
||||
var iCurrDist = (int)Mobile.GetDistanceToSqrt(master);
|
||||
var bRun = iCurrDist > 5;
|
||||
|
||||
WalkMobileRange(master, 2, bRun, 0, 1);
|
||||
WalkMobileRange(master, 2, 0, 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ description: >
|
|||
- `BaseCreature(AI, Fight, 10, 1, 0.2, 0.4)` -> `BaseCreature(AI, Fight)` (extra params default)
|
||||
- `Name = "text"` -> `public override string DefaultName => "text";`
|
||||
- Expression-bodied overrides: `public override int Meat { get { return 1; } }` -> `public override int Meat => 1;`
|
||||
- AI movement calls lose the `run` flag: `MoveTo(m, true, range)` -> `MoveTo(m, range)` (also `WalkMobileRange`, `ApproachTarget`, `MoveToPoint`, `PathFollower.Follow`); the Running bit is derived from step pace -> `dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md` § AI Movement
|
||||
|
||||
## Anti-Patterns
|
||||
- Using `_field--` instead of `Property--` (bypasses MarkDirty tracking)
|
||||
|
|
|
|||
|
|
@ -27,8 +27,9 @@ description: >
|
|||
(`ActiveSpeed`/`PassiveSpeed`, seconds per AI decision) and move
|
||||
(`ActiveMoveSpeed`/`PassiveMoveSpeed`, seconds per step; inherits think until
|
||||
overridden). Prefer `npc-speeds.json` buckets (`SpeedClass`); `SetSpeed()` sets think
|
||||
AND clears move overrides, `SetMoveSpeed()` sets move only -- see
|
||||
`dev-docs/content-patterns.md` § Creature Speeds
|
||||
AND clears move overrides, `SetMoveSpeed()` sets move only. The client `Running` bit is
|
||||
derived from the step pace (`BaseAI.ShouldRun`); movement APIs take no run argument --
|
||||
see `dev-docs/content-patterns.md` § Creature Speeds
|
||||
8. **`OnThink` overrides must be excess-call tolerant** -- it fires more often than the
|
||||
think cadence (player commands prod it; speed-ups reschedule it). Gate consequential
|
||||
work on a tick-count deadline (subtraction form) or make it idempotent; bare per-call
|
||||
|
|
|
|||
|
|
@ -283,6 +283,18 @@ ClearMoveSpeed(); // back to inheriting the think clock
|
|||
All four are `[props`-tunable per instance (move values: set `0` to re-inherit); per-instance
|
||||
move overrides serialize. Being badly hurt slows steps, never decisions (RunUO parity).
|
||||
|
||||
The client's `Running` bit is derived from the step pace, never passed by callers
|
||||
(`BaseAI.ShouldRun`, stamped in `DoMoveImpl`): a step shorter than the client's walk
|
||||
interpolation — 400 ms on foot, 200 ms mounted/flying (`Movement.WalkFootDelay` /
|
||||
`WalkMountDelay`) — is flagged as a run, or the client falls behind and snaps. An isolated
|
||||
step (resuming after at least a walk interval standing) goes out as a walk regardless of
|
||||
pace — the client renders each step alone, so a run-flagged single step darts — unless the
|
||||
pace beats the run interpolation (a true sprinter), where a walk-rendered first step would
|
||||
flood the client's step queue. Movement APIs (`MoveTo`, `WalkMobileRange`,
|
||||
`ApproachTarget`, `MoveToPoint`) take no run argument; to make a creature run, make it
|
||||
fast. Creatures step at most once per `CurrentMoveSpeed` period, paced from the step just
|
||||
taken — a stall never banks catch-up steps, so a resumed chase restarts at full pace.
|
||||
|
||||
### OnThink: the excess-call contract
|
||||
|
||||
`OnThink()` is a scheduler pass, not an action. The AI timer calls it *at least* at the
|
||||
|
|
|
|||
|
|
@ -474,6 +474,29 @@ The extra parameters (RangePerception, RangeFight, ActiveSpeed, PassiveSpeed) ha
|
|||
| `Name = "a creature"` in constructor | `public override string DefaultName => "a creature";` |
|
||||
| `get { return value; }` | `=> value;` expression-bodied |
|
||||
|
||||
## AI Movement: No `run` Argument
|
||||
|
||||
RunUO's movement calls took a `run` flag that callers set inconsistently (`true` in
|
||||
combat, `false` for pets, gated by `dist > 5` inside `MoveTo`). The flag only selects the
|
||||
client's per-step animation time, so ModernUO derives it from the creature's step pace
|
||||
(`BaseAI.ShouldRun`) and the parameter is gone:
|
||||
|
||||
```csharp
|
||||
// RunUO
|
||||
MoveTo(combatant, true, m_Mobile.RangeFight);
|
||||
WalkMobileRange(m_Mobile.ControlMaster, 1, false, 0, 1);
|
||||
|
||||
// ModernUO
|
||||
MoveTo(combatant, Mobile.RangeFight);
|
||||
WalkMobileRange(Mobile.ControlMaster, 1, 0, 1);
|
||||
```
|
||||
|
||||
`ApproachTarget`, `MoveToPoint` and `PathFollower.Follow` lose the argument the same way.
|
||||
To make a creature run, make it fast (`SetMoveSpeed` / `npc-speeds.json`), not flagged.
|
||||
An isolated step (after the creature stood for at least a walk interval) goes out as a
|
||||
walk regardless of pace — only a continuing cadence, or a pace faster than the run
|
||||
interpolation, flags run.
|
||||
|
||||
## Item Name Changes
|
||||
|
||||
```csharp
|
||||
|
|
|
|||
|
|
@ -130,6 +130,9 @@ Alphabetical by RunUO API name. Use Ctrl+F / Cmd+F to search.
|
|||
| `writer.WriteEncodedInt(value)` | `writer.WriteEncodedInt(value)` | Same |
|
||||
| `InvalidateProperties()` | `InvalidateProperties()` | Same, or use `[InvalidateProperties]` |
|
||||
| `this.MarkDirty()` | `this.MarkDirty()` | NEW — required in custom setters |
|
||||
| `MoveTo(m, run, range)` | `MoveTo(m, range)` | `run` removed; the Running bit is derived from the step pace (`BaseAI.ShouldRun`) |
|
||||
| `WalkMobileRange(m, steps, run, min, max)` | `WalkMobileRange(m, steps, min, max)` | Same |
|
||||
| `PathFollower.Follow(run, range)` | `Follow(range)` | Same |
|
||||
|
||||
## Networking
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue