feat: Adds independent think/move clocks for creature AI to fix speed (#2591)
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.
This commit is contained in:
parent
8e39da2810
commit
e7f85d404d
10 changed files with 620 additions and 40 deletions
|
|
@ -265,8 +265,12 @@ namespace Server.Mobiles
|
|||
private double _passiveSpeed;
|
||||
private double _currentSpeed;
|
||||
|
||||
// Herding - Overrides the AI to force the mob to move to a specific location
|
||||
// Thinking: 0.3s, Movement: 0.6s.
|
||||
// Movement clock (seconds per step); 0 = inherit the matching think value.
|
||||
private double _activeMoveSpeed;
|
||||
private double _passiveMoveSpeed;
|
||||
|
||||
// Herding - forces the mob to walk to a specific location, paced by the movement
|
||||
// clock at HerdingMoveSpeed. Thinking is unaffected.
|
||||
private IPoint2D _targetLocation;
|
||||
|
||||
private int m_DamageMax = -1;
|
||||
|
|
@ -342,6 +346,7 @@ namespace Server.Mobiles
|
|||
FightMode = mode;
|
||||
|
||||
GetSpeeds(out var activeSpeed, out var passiveSpeed);
|
||||
GetMoveSpeeds(out _activeMoveSpeed, out _passiveMoveSpeed);
|
||||
|
||||
ActiveSpeed = activeSpeed;
|
||||
PassiveSpeed = passiveSpeed;
|
||||
|
|
@ -673,6 +678,7 @@ namespace Server.Mobiles
|
|||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int RangeHome { get; set; } = 10;
|
||||
|
||||
/// <summary>Seconds per AI decision while engaged; see <see cref="ActiveMoveSpeed"/> for movement pace.</summary>
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public virtual double ActiveSpeed
|
||||
{
|
||||
|
|
@ -686,6 +692,7 @@ namespace Server.Mobiles
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>Seconds per AI decision while idle; see <see cref="PassiveMoveSpeed"/> for movement pace.</summary>
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public virtual double PassiveSpeed
|
||||
{
|
||||
|
|
@ -700,21 +707,37 @@ namespace Server.Mobiles
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>Seconds per step while engaged. Inherits <see cref="ActiveSpeed"/>; set 0 to re-inherit.</summary>
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public virtual double ActiveMoveSpeed
|
||||
{
|
||||
get => _activeMoveSpeed > 0 ? _activeMoveSpeed : _activeSpeed;
|
||||
set => _activeMoveSpeed = value > 0 ? value : 0;
|
||||
}
|
||||
|
||||
/// <summary>Seconds per step while idle. Inherits <see cref="PassiveSpeed"/>; set 0 to re-inherit.</summary>
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public virtual double PassiveMoveSpeed
|
||||
{
|
||||
get => _passiveMoveSpeed > 0 ? _passiveMoveSpeed : _passiveSpeed;
|
||||
set => _passiveMoveSpeed = value > 0 ? value : 0;
|
||||
}
|
||||
|
||||
// Herded creatures walk at a fixed standard pace regardless of their own speed
|
||||
// (RunUO's forced 0.3, without its TransformMoveDelay inflation to 0.6).
|
||||
private const double HerdingMoveSpeed = 0.3;
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public IPoint2D TargetLocation
|
||||
{
|
||||
get => _targetLocation;
|
||||
set
|
||||
{
|
||||
_targetLocation = value;
|
||||
AIObject?.OnCurrentSpeedChanged();
|
||||
}
|
||||
set => _targetLocation = value;
|
||||
}
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public double CurrentSpeed
|
||||
{
|
||||
get => _targetLocation != null ? 0.3 : _currentSpeed;
|
||||
get => _currentSpeed;
|
||||
set
|
||||
{
|
||||
if (Math.Abs(_currentSpeed - value) > 0.0001)
|
||||
|
|
@ -725,8 +748,26 @@ namespace Server.Mobiles
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolved seconds per step: a verbatim active/passive <see cref="CurrentSpeed"/>
|
||||
/// maps to the matching movement value; a bespoke pace stays fused to both clocks.
|
||||
/// A herded creature is always driven at <see cref="HerdingMoveSpeed"/>.
|
||||
/// </summary>
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public double MoveSpeedMod { get; set; }
|
||||
public double CurrentMoveSpeed
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_targetLocation != null)
|
||||
{
|
||||
return HerdingMoveSpeed;
|
||||
}
|
||||
|
||||
return _currentSpeed == _activeSpeed ? ActiveMoveSpeed
|
||||
: _currentSpeed == _passiveSpeed ? PassiveMoveSpeed
|
||||
: _currentSpeed;
|
||||
}
|
||||
}
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public Point3D Home
|
||||
|
|
@ -1850,7 +1891,7 @@ namespace Server.Mobiles
|
|||
{
|
||||
base.Serialize(writer);
|
||||
|
||||
writer.Write(21); // version
|
||||
writer.Write(22); // version
|
||||
|
||||
writer.Write((int)m_CurrentAI);
|
||||
writer.Write((int)m_DefaultAI);
|
||||
|
|
@ -1970,6 +2011,10 @@ namespace Server.Mobiles
|
|||
|
||||
// Version 19
|
||||
writer.Write(HomeMap);
|
||||
|
||||
// Version 22 (0 = inherit the matching think value)
|
||||
writer.Write(_activeMoveSpeed);
|
||||
writer.Write(_passiveMoveSpeed);
|
||||
}
|
||||
|
||||
public override void Deserialize(IGenericReader reader)
|
||||
|
|
@ -2173,6 +2218,16 @@ namespace Server.Mobiles
|
|||
HomeMap = reader.ReadMap();
|
||||
}
|
||||
|
||||
if (version >= 22)
|
||||
{
|
||||
_activeMoveSpeed = reader.ReadDouble();
|
||||
_passiveMoveSpeed = reader.ReadDouble();
|
||||
}
|
||||
else
|
||||
{
|
||||
MigrateMoveSpeeds();
|
||||
}
|
||||
|
||||
if (version <= 14 && m_Paragon && Hue == 0x31)
|
||||
{
|
||||
Hue = Paragon.Hue; // Paragon hue fixed, should now be 0x501.
|
||||
|
|
@ -4586,13 +4641,78 @@ namespace Server.Mobiles
|
|||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the think clock and clears movement overrides (legacy one-clock semantics);
|
||||
/// use <see cref="SetMoveSpeed"/> for an independent movement pace.
|
||||
/// </summary>
|
||||
public void SetSpeed(double active, double passive, bool isPassive = true)
|
||||
{
|
||||
ActiveSpeed = active;
|
||||
PassiveSpeed = passive;
|
||||
ClearMoveSpeed();
|
||||
CurrentSpeed = isPassive ? PassiveSpeed : ActiveSpeed;
|
||||
}
|
||||
|
||||
/// <summary>Sets only the movement clock (seconds per step).</summary>
|
||||
public void SetMoveSpeed(double active, double passive)
|
||||
{
|
||||
ActiveMoveSpeed = active;
|
||||
PassiveMoveSpeed = passive;
|
||||
}
|
||||
|
||||
/// <summary>Clears movement overrides; steps pace off the think clock again.</summary>
|
||||
public void ClearMoveSpeed()
|
||||
{
|
||||
_activeMoveSpeed = 0;
|
||||
_passiveMoveSpeed = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scales movement overrides (paragon and similar buffs). Inheriting values stay
|
||||
/// inheriting — they already follow the scaled think clock.
|
||||
/// </summary>
|
||||
public void ScaleMoveSpeed(double scalar)
|
||||
{
|
||||
if (_activeMoveSpeed > 0)
|
||||
{
|
||||
_activeMoveSpeed *= scalar;
|
||||
}
|
||||
|
||||
if (_passiveMoveSpeed > 0)
|
||||
{
|
||||
_passiveMoveSpeed *= scalar;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snaps speeds within rounding distance of the creature's table values back to
|
||||
/// exact. A scaling buff that divides then multiplies can drift by an ulp (e.g.
|
||||
/// 0.9 and 0.45 through 1.2), which would read as hand-tuned; call after undoing
|
||||
/// such a buff. Genuinely tuned speeds are nowhere near the epsilon and keep.
|
||||
/// </summary>
|
||||
public void SnapSpeedsToTable()
|
||||
{
|
||||
GetSpeeds(out var activeSpeed, out var passiveSpeed);
|
||||
|
||||
if (Math.Abs(_activeSpeed - activeSpeed) < 0.0001 && Math.Abs(_passiveSpeed - passiveSpeed) < 0.0001)
|
||||
{
|
||||
_activeSpeed = activeSpeed;
|
||||
_passiveSpeed = passiveSpeed;
|
||||
}
|
||||
|
||||
GetMoveSpeeds(out var activeMoveSpeed, out var passiveMoveSpeed);
|
||||
|
||||
if (activeMoveSpeed > 0 && Math.Abs(_activeMoveSpeed - activeMoveSpeed) < 0.0001)
|
||||
{
|
||||
_activeMoveSpeed = activeMoveSpeed;
|
||||
}
|
||||
|
||||
if (passiveMoveSpeed > 0 && Math.Abs(_passiveMoveSpeed - passiveMoveSpeed) < 0.0001)
|
||||
{
|
||||
_passiveMoveSpeed = passiveMoveSpeed;
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public void SetCurrentSpeedToActive() => CurrentSpeed = ActiveSpeed;
|
||||
|
||||
|
|
@ -4906,6 +5026,25 @@ namespace Server.Mobiles
|
|||
NPCSpeeds.GetSpeeds(this, out activeSpeed, out passiveSpeed);
|
||||
}
|
||||
|
||||
public virtual void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed)
|
||||
{
|
||||
NPCSpeeds.GetMoveSpeeds(this, out activeMoveSpeed, out passiveMoveSpeed);
|
||||
}
|
||||
|
||||
// Pre-v22 saves carry no movement clock. A creature whose serialized think speeds
|
||||
// still match what it would spawn with today was never hand-tuned: adopt today's
|
||||
// move values so existing worlds (and pets) pick up npc-speeds pacing without a
|
||||
// respawn. Tuned creatures keep movement inheriting their think clock.
|
||||
internal void MigrateMoveSpeeds()
|
||||
{
|
||||
GetSpeeds(out var activeSpeed, out var passiveSpeed);
|
||||
|
||||
if (_activeSpeed == activeSpeed && _passiveSpeed == passiveSpeed)
|
||||
{
|
||||
GetMoveSpeeds(out _activeMoveSpeed, out _passiveMoveSpeed);
|
||||
}
|
||||
}
|
||||
|
||||
public virtual void DropBackpack()
|
||||
{
|
||||
var backpack = Backpack;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue