Fixes the long-standing "monster loses track of a player who runs around a corner" reports. Root causes and fixes: 1. Movement contract: MoveTo/ApproachTarget returned false on every healthy mid-chase tick, so MeleeAI's inherited "move failed and >RangePerception+1 -> Guard" clause (RunUO only ran it on real blockage) fired every tick of every chase. They now report failure only on genuine movement failure (no step with no working path, or approach give-up). ArcherAI's equivalent clause moves to the hard leash. 2. Last-known-position pursuit: while a combatant is in LOS its position is recorded each think tick. When the target vanishes (corner, hiding, recall) the creature investigates the last-seen spot, stands guard there ~10s (restoring RunUO's guard grace that had decayed to a single tick), and re-engages instantly if the same target re-enters view - bypassing the 10s reacquire throttle. 3. ChaseLeashRange (virtual, default RangePerception * 2 = 32 tiles) replaces the inline RangePerception * 3; per-creature tunable. 4. Group movement is now a crowding refinement, not a movement mode: it only engages near the target when allies contest the ring, and blocked/wall-slid steps escalate to the pathfinder instead of reporting success - previously any creature with one ally on the same target greedy-stepped for the whole chase and never pathfound. 5. Mages close distance when geometry (not hiding) blocks line of sight instead of standing at a wall holding a spell until the 60s combatant expiry. 6. Move budget: one actual step per AI think tick (half-step NextMove charge). Code paths that attempted several moves in one tick could cross multiple tiles at once - visible as "warping" when crowded creatures jockey for position. Blocked attempts consume nothing, so retry ladders are unaffected. CanMoveNow is wraparound-safe now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
224 lines
6.8 KiB
C#
224 lines
6.8 KiB
C#
/*************************************************************************
|
|
* ModernUO *
|
|
* Copyright 2019-2026 - ModernUO Development Team *
|
|
* Email: hi@modernuo.com *
|
|
* File: AIGroupMovement.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.Collections.Generic;
|
|
using System.Runtime.CompilerServices;
|
|
using Server.Collections;
|
|
|
|
namespace Server.Mobiles;
|
|
|
|
public abstract partial class BaseAI
|
|
{
|
|
private static readonly Dictionary<BaseCreature, Point3D> _reservedPositions = new();
|
|
private static long _lastGroupUpdateTime;
|
|
|
|
private static void CleanupReservedPositions()
|
|
{
|
|
foreach (var (m, p) in _reservedPositions)
|
|
{
|
|
if (m?.Deleted != false || m.GetDistanceToSqrt(p) < 1)
|
|
{
|
|
_reservedPositions.Remove(m);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Crowding refinement for the final approach: engages only near the target when allies
|
|
/// contest the ring, so creatures spread instead of stacking. Chasing any real distance
|
|
/// always uses the pathfinding approach primitive.
|
|
/// </summary>
|
|
private bool UseGroupMovement(Mobile target, int range) =>
|
|
Mobile.Combatant == target
|
|
&& !Mobile.Controlled
|
|
&& Mobile.InRange(target, range + 2)
|
|
&& CountCrowdingAllies(target, range) > 0;
|
|
|
|
private int CountCrowdingAllies(Mobile target, int range)
|
|
{
|
|
var crowding = 0;
|
|
|
|
foreach (var m in target.GetMobilesInRange(range + 1))
|
|
{
|
|
if (m != Mobile && m.Combatant == target && m is BaseCreature { Controlled: false } bc
|
|
&& bc.Team == Mobile.Team)
|
|
{
|
|
crowding++;
|
|
}
|
|
}
|
|
|
|
return crowding;
|
|
}
|
|
|
|
public static bool MoveToWithGroup(BaseAI ai, Mobile target, bool run, int range)
|
|
{
|
|
if (Core.TickCount - _lastGroupUpdateTime > 1000)
|
|
{
|
|
CleanupReservedPositions();
|
|
_lastGroupUpdateTime = Core.TickCount;
|
|
}
|
|
|
|
var mobile = ai.Mobile;
|
|
var allies = ai.GetNearbyAllies(target);
|
|
var optimalPosition = ai.CalculateOptimalPosition(target, ref allies, range);
|
|
try
|
|
{
|
|
if (optimalPosition == Point3D.Zero)
|
|
{
|
|
|
|
return ai.MoveToWithCollisionAvoidance(target, run, range);
|
|
}
|
|
|
|
_reservedPositions[mobile] = optimalPosition;
|
|
|
|
var direction = mobile.GetDirectionTo(optimalPosition);
|
|
|
|
if (Utility.Random(3) == 0)
|
|
{
|
|
direction = GetAdjustedDirection(direction);
|
|
}
|
|
|
|
var res = ai.DoMoveImpl(direction, true);
|
|
|
|
if (res is MoveResult.Success or MoveResult.BadState)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// A blocked or wall-slid step is not progress — route around the obstacle.
|
|
return ai.ApproachTarget(target, run, range);
|
|
}
|
|
finally
|
|
{
|
|
allies.Dispose();
|
|
}
|
|
}
|
|
|
|
private PooledRefList<BaseCreature> GetNearbyAllies(Mobile target)
|
|
{
|
|
var allies = PooledRefList<BaseCreature>.Create();
|
|
|
|
foreach (var m in Mobile.GetMobilesInRange(8))
|
|
{
|
|
if (m != Mobile && m.Combatant == target && m is BaseCreature { Controlled: false } bc
|
|
&& bc.Team == Mobile.Team)
|
|
{
|
|
allies.Add(bc);
|
|
}
|
|
}
|
|
|
|
return allies;
|
|
}
|
|
|
|
private Point3D CalculateOptimalPosition(Mobile target, ref PooledRefList<BaseCreature> allies, int range)
|
|
{
|
|
var targetLoc = target.Location;
|
|
var bestPosition = Point3D.Zero;
|
|
var bestScore = -1.0;
|
|
|
|
for (var x = -range; x <= range; x++)
|
|
{
|
|
for (var y = -range; y <= range; y++)
|
|
{
|
|
if (Math.Abs(x) + Math.Abs(y) != range)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var testLoc = new Point3D(targetLoc.X + x, targetLoc.Y + y, targetLoc.Z);
|
|
var distance = Mobile.GetDistanceToSqrt(testLoc);
|
|
|
|
if (distance < range ||
|
|
distance > range + 3 || !CanMoveTo(testLoc))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var score = ScorePosition(testLoc, distance, target, ref allies);
|
|
|
|
if (score > bestScore)
|
|
{
|
|
bestScore = score;
|
|
bestPosition = testLoc;
|
|
|
|
if (score > 20)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return bestPosition;
|
|
}
|
|
|
|
private double ScorePosition(Point3D position, double currentDistance, Mobile target, ref PooledRefList<BaseCreature> allies)
|
|
{
|
|
var score = -(currentDistance * 2);
|
|
|
|
for (var i = 0; i < allies.Count; i++)
|
|
{
|
|
var ally = allies[i];
|
|
var allyDistance = ally.GetDistanceToSqrt(position);
|
|
|
|
if (allyDistance < 2)
|
|
{
|
|
score -= 50;
|
|
}
|
|
else if (allyDistance < 3)
|
|
{
|
|
score -= 20;
|
|
}
|
|
}
|
|
|
|
foreach (var (m, p) in _reservedPositions)
|
|
{
|
|
if (m != Mobile && p.GetDistanceToSqrt(position) < 2)
|
|
{
|
|
score -= 30;
|
|
}
|
|
}
|
|
|
|
if (Mobile.Map != null && Mobile.Map.LineOfSight(position, target.Location))
|
|
{
|
|
score += 10;
|
|
}
|
|
|
|
return score + Utility.RandomDouble() * 5;
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
private bool CanMoveTo(Point3D location) =>
|
|
Mobile.Map?.CanFit(location.X, location.Y, location.Z, 16, false, false) == true;
|
|
|
|
private static Direction GetAdjustedDirection(Direction original)
|
|
{
|
|
var adjustment = Utility.Random(3) - 1;
|
|
var newDir = (int)original + adjustment;
|
|
|
|
if (newDir < 0)
|
|
{
|
|
return (Direction)(newDir + 8);
|
|
}
|
|
|
|
if (newDir >= 8)
|
|
{
|
|
return (Direction)(newDir - 8);
|
|
}
|
|
|
|
return (Direction)newDir;
|
|
}
|
|
}
|