feat: complete Phase 4 McClient integration for A* pathfinding

- Fix MoveHelper.IsOpenGate: MangroveWood -> MangroveFenceGate
- Fix ResetStateForTransfer to cancel and clear pathSegmentManager
- Fix GetCurrentMovementGoal to return correct goal during A* navigation
- Fix SetMovementSpeed(Sneak) speed value consistency (2 -> 1)
- Migrate /pathfind command to use MoveToAStar + PathSegmentManager
- Add NavigateToGoal(IGoal) to McClient for flexible goal navigation
- Refactor MoveToAStar to delegate to NavigateToGoal
- Add ChatBot API: NavigateTo, CancelMovement, GetCurrentMovementGoal
- Expose PathSegmentManager.Goal property for external goal inspection

Made-with: Cursor
This commit is contained in:
BruceChen 2026-04-12 00:03:43 +08:00
parent 53082d387e
commit 8ece75acc3
5 changed files with 78 additions and 113 deletions

View file

@ -1,12 +1,7 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Brigadier.NET;
using Brigadier.NET.Builder;
using MinecraftClient.CommandHandler;
using MinecraftClient.Mapping;
using MinecraftClient.Pathing.Core;
using MinecraftClient.Pathing.Goals;
using static MinecraftClient.CommandHandler.CmdResult;
namespace MinecraftClient.Commands
@ -38,7 +33,7 @@ namespace MinecraftClient.Commands
return r.SetAndReturn(GetCmdDescTranslated());
}
private int DoPathfind(CmdResult r, Location goal)
private static int DoPathfind(CmdResult r, Location goal)
{
McClient handler = CmdResult.currentHandler!;
if (!handler.GetTerrainEnabled())
@ -47,94 +42,9 @@ namespace MinecraftClient.Commands
Location current = handler.GetCurrentLocation();
goal.ToAbsolute(current);
int startX = (int)Math.Floor(current.X);
int startY = (int)Math.Floor(current.Y);
int startZ = (int)Math.Floor(current.Z);
int goalX = (int)Math.Floor(goal.X);
int goalY = (int)Math.Floor(goal.Y);
int goalZ = (int)Math.Floor(goal.Z);
var (success, message) = handler.MoveToAStar(goal, timeoutMs: 10000);
handler.Log.Info($"[Pathfind] Planning from ({startX},{startY},{startZ}) to ({goalX},{goalY},{goalZ})");
var ctx = new CalculationContext(
handler.GetWorld(),
canSprint: true,
maxFallHeight: 3);
var finder = new AStarPathFinder();
finder.DebugLog = msg => handler.Log.Info(msg);
var goalObj = new GoalBlock(goalX, goalY, goalZ);
Task.Run(() =>
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var result = finder.Calculate(ctx, startX, startY, startZ, goalObj, cts.Token, timeoutMs: 10000);
handler.Log.Info($"[Pathfind] Result: {result.Status}, {result.Path.Count} nodes, " +
$"{result.NodesExplored} explored, {result.ElapsedMs}ms");
if (result.Path.Count > 1)
{
handler.Log.Info("[Pathfind] Path waypoints:");
for (int i = 0; i < result.Path.Count; i++)
{
var n = result.Path[i];
handler.Log.Info($" [{i}] ({n.X},{n.Y},{n.Z}) via {n.MoveUsed}");
}
handler.Log.Info("[Pathfind] Beginning movement along path...");
FollowPath(handler, result);
}
else
{
handler.Log.Warn("[Pathfind] No path found!");
}
}
catch (Exception ex)
{
handler.Log.Warn($"[Pathfind] Exception: {ex.Message}");
}
});
return r.SetAndReturn(Status.Done, string.Format(Translations.cmd_pathfind_started, goalX, goalY, goalZ));
}
private static void FollowPath(McClient handler, PathResult result)
{
for (int i = 1; i < result.Path.Count; i++)
{
var node = result.Path[i];
var target = new Location(node.X + 0.5, node.Y, node.Z + 0.5);
handler.Log.Info($"[Pathfind] Moving to waypoint [{i}/{result.Path.Count - 1}]: ({node.X},{node.Y},{node.Z}) via {node.MoveUsed}");
bool success = handler.MoveTo(target, allowUnsafe: true, allowDirectTeleport: false, timeout: TimeSpan.FromSeconds(10));
if (!success)
{
handler.Log.Warn($"[Pathfind] Sub-path failed for waypoint [{i}], using direct move");
handler.MoveTo(target, allowUnsafe: true, allowDirectTeleport: true);
}
int maxWaitTicks = 200;
int waited = 0;
while (handler.ClientIsMoving() && waited < maxWaitTicks)
{
Thread.Sleep(50);
waited++;
}
var cur = handler.GetCurrentLocation();
double dx = cur.X - target.X;
double dz = cur.Z - target.Z;
double horizDist = Math.Sqrt(dx * dx + dz * dz);
handler.Log.Info($"[Pathfind] Waypoint [{i}] done, pos=({cur.X:F2},{cur.Y:F2},{cur.Z:F2}), dist={horizDist:F2}");
}
handler.Log.Info("[Pathfind] Path execution complete!");
return r.SetAndReturn(success ? Status.Done : Status.Fail, message);
}
}
}

View file

@ -567,6 +567,8 @@ namespace MinecraftClient
isUnderSlab = false;
path = null;
pathTarget = null;
pathSegmentManager?.Cancel();
pathSegmentManager = null;
_yaw = null;
_pitch = null;
LastDigPosition = null;
@ -1716,11 +1718,11 @@ namespace MinecraftClient
}
/// <summary>
/// Navigate to a goal using the new A* pathfinder.
/// Runs the search, converts the result into the legacy Queue path, and starts movement.
/// Navigate to a goal using the new A* pathfinder and template-based execution.
/// Accepts any IGoal for flexible target specification.
/// Returns a description of the result for UI feedback.
/// </summary>
public (bool success, string message) MoveToAStar(Location goal, long timeoutMs = 5000)
public (bool success, string message) NavigateToGoal(Pathing.Goals.IGoal goal, long timeoutMs = 5000)
{
lock (locationLock)
{
@ -1736,21 +1738,12 @@ namespace MinecraftClient
if (!ctx.CanWalkThrough(sx, sy, sz) && ctx.CanWalkThrough(sx, sy + 1, sz))
sy++;
int gx = (int)Math.Floor(goal.X);
int gy = (int)Math.Floor(goal.Y);
int gz = (int)Math.Floor(goal.Z);
if (!ctx.CanWalkThrough(gx, gy, gz) && ctx.CanWalkThrough(gx, gy + 1, gz))
gy++;
Log.Info($"[Goto] A* search from ({sx},{sy},{sz}) to ({gx},{gy},{gz}) " +
$"[raw pos=({location.X:F2},{location.Y:F2},{location.Z:F2})]");
Log.Info($"[Navigate] A* search from ({sx},{sy},{sz}) to {goal}");
using var cts = new CancellationTokenSource();
var pathGoal = new Pathing.Goals.GoalBlock(gx, gy, gz);
var result = finder.Calculate(ctx, sx, sy, sz, pathGoal, cts.Token, timeoutMs);
var result = finder.Calculate(ctx, sx, sy, sz, goal, cts.Token, timeoutMs);
Log.Info($"[Goto] A* result: {result.Status}, nodes={result.NodesExplored}, " +
Log.Info($"[Navigate] A* result: {result.Status}, nodes={result.NodesExplored}, " +
$"time={result.ElapsedMs}ms, path length={result.Path.Count}");
if (result.Status == Pathing.Core.PathStatus.Failed || result.Path.Count < 2)
@ -1762,7 +1755,7 @@ namespace MinecraftClient
for (int i = 1; i < result.Path.Count; i++)
{
var node = result.Path[i];
Log.Debug($"[Goto] seg[{i - 1}] = {node.MoveUsed}: ({node.X},{node.Y},{node.Z})");
Log.Debug($"[Navigate] seg[{i - 1}] = {node.MoveUsed}: ({node.X},{node.Y},{node.Z})");
}
pathTarget = null;
@ -1771,7 +1764,7 @@ namespace MinecraftClient
pathSegmentManager = new Pathing.Execution.PathSegmentManager(
debugLog: msg => Log.Debug(msg),
infoLog: msg => Log.Info(msg));
pathSegmentManager.StartNavigation(pathGoal, result);
pathSegmentManager.StartNavigation(goal, result);
string statusStr = result.Status == Pathing.Core.PathStatus.Partial ? " (partial)" : "";
return (true, string.Format(Translations.cmd_goto_success,
@ -1779,6 +1772,28 @@ namespace MinecraftClient
}
}
/// <summary>
/// Navigate to a block location using the new A* pathfinder and template-based execution.
/// Convenience overload that creates a GoalBlock from the location.
/// Returns a description of the result for UI feedback.
/// </summary>
public (bool success, string message) MoveToAStar(Location goal, long timeoutMs = 5000)
{
int gx = (int)Math.Floor(goal.X);
int gy = (int)Math.Floor(goal.Y);
int gz = (int)Math.Floor(goal.Z);
lock (locationLock)
{
var ctx = new Pathing.Core.CalculationContext(world);
if (!ctx.CanWalkThrough(gx, gy, gz) && ctx.CanWalkThrough(gx, gy + 1, gz))
gy++;
}
var pathGoal = new Pathing.Goals.GoalBlock(gx, gy, gz);
return NavigateToGoal(pathGoal, timeoutMs);
}
/// <summary>
/// Send a chat message or command to the server
/// </summary>
@ -3436,7 +3451,16 @@ namespace MinecraftClient
/// <returns>Current goal of movement. Location.Zero if not set.</returns>
public Location GetCurrentMovementGoal()
{
return (ClientIsMoving() || path is null) ? Location.Zero : path.Last();
if (pathSegmentManager is not null && pathSegmentManager.IsNavigating)
{
if (pathSegmentManager.Goal is Pathing.Goals.GoalBlock gb)
return new Location(gb.X + 0.5, gb.Y, gb.Z + 0.5);
}
if (path is not null && path.Count > 0)
return path.Last();
return Location.Zero;
}
/// <summary>
@ -3463,7 +3487,7 @@ namespace MinecraftClient
{
case MovementType.Sneak:
// https://minecraft.wiki/w/Sneaking#Effects - Sneaking 1.31m/s
Config.Main.Advanced.MovementSpeed = 2;
Config.Main.Advanced.MovementSpeed = 1;
break;
case MovementType.Walk:
// https://minecraft.wiki/w/Walking#Usage - Walking 4.317 m/s

View file

@ -23,6 +23,7 @@ namespace MinecraftClient.Pathing.Execution
public bool IsNavigating => _executor is not null && !_executor.IsComplete;
public int ReplanCount => _replanCount;
public IGoal? Goal => _goal;
public PathSegmentManager(Action<string>? debugLog = null, Action<string>? infoLog = null)
{

View file

@ -105,7 +105,7 @@ namespace MinecraftClient.Pathing.Moves
{
return mat is Material.AcaciaFenceGate or Material.BirchFenceGate
or Material.CrimsonFenceGate or Material.DarkOakFenceGate
or Material.JungleFenceGate or Material.MangroveWood
or Material.JungleFenceGate or Material.MangroveFenceGate
or Material.OakFenceGate or Material.SpruceFenceGate
or Material.WarpedFenceGate or Material.CherryFenceGate
or Material.BambooFenceGate or Material.PaleOakFenceGate;

View file

@ -1246,6 +1246,18 @@ namespace MinecraftClient.Scripting
return Handler.MoveTo(location, allowUnsafe, allowDirectTeleport, maxOffset, minOffset, timeout);
}
/// <summary>
/// Navigate to a goal using A* pathfinding with template-based execution.
/// Supports GoalBlock, GoalXZ, GoalNear, GoalComposite for flexible targeting.
/// </summary>
/// <param name="goal">Target goal (GoalBlock, GoalNear, GoalXZ, etc.)</param>
/// <param name="timeoutMs">Maximum pathfinding computation time in milliseconds</param>
/// <returns>Tuple of (success, descriptive message)</returns>
protected (bool success, string message) NavigateTo(Pathing.Goals.IGoal goal, long timeoutMs = 5000)
{
return Handler.NavigateToGoal(goal, timeoutMs);
}
/// <summary>
/// Check if the client is currently processing a Movement.
/// </summary>
@ -1255,6 +1267,24 @@ namespace MinecraftClient.Scripting
return Handler.ClientIsMoving();
}
/// <summary>
/// Cancel the current movement, stopping both legacy and A* pathfinding.
/// </summary>
/// <returns>true if there was an active movement that was cancelled</returns>
protected bool CancelMovement()
{
return Handler.CancelMovement();
}
/// <summary>
/// Get the current movement goal location.
/// Returns Location.Zero if no movement is active.
/// </summary>
protected Location GetCurrentMovementGoal()
{
return Handler.GetCurrentMovementGoal();
}
/// <summary>
/// Look at the specified location
/// </summary>