feat: add /goto command with A* pathfinder integration and MoveFall

- Add MoveFall move for straight-down falls beyond MoveDescend range
- Register MoveFall in default move set
- Create /goto command using new A* pathfinder
- Add MoveToAStar() method to McClient bridging A* results to existing
  path execution system (Queue<Location> + UpdatePathfindingInput)
- Add translation entries for goto command

Made-with: Cursor
This commit is contained in:
BruceChen 2026-04-11 02:27:10 +08:00
parent deb1bc47cc
commit 77c5f88168
6 changed files with 217 additions and 0 deletions

View file

@ -0,0 +1,50 @@
using Brigadier.NET;
using Brigadier.NET.Builder;
using MinecraftClient.CommandHandler;
using MinecraftClient.Mapping;
using static MinecraftClient.CommandHandler.CmdResult;
namespace MinecraftClient.Commands
{
public class Goto : Command
{
public override string CmdName => "goto";
public override string CmdUsage => "goto <x y z>";
public override string CmdDesc => Translations.cmd_goto_desc;
public override void RegisterCommand(CommandDispatcher<CmdResult> dispatcher)
{
dispatcher.Register(l => l.Literal("help")
.Then(l => l.Literal(CmdName)
.Executes(r => GetUsage(r.Source, string.Empty)))
);
dispatcher.Register(l => l.Literal(CmdName)
.Then(l => l.Argument("location", MccArguments.Location())
.Executes(r => DoGoto(r.Source, MccArguments.GetLocation(r, "location"))))
.Then(l => l.Literal("_help")
.Executes(r => GetUsage(r.Source, string.Empty))
.Redirect(dispatcher.GetRoot().GetChild("help").GetChild(CmdName)))
);
}
private int GetUsage(CmdResult r, string? cmd)
{
return r.SetAndReturn(GetCmdDescTranslated());
}
private static int DoGoto(CmdResult r, Location goal)
{
McClient handler = CmdResult.currentHandler!;
if (!handler.GetTerrainEnabled())
return r.SetAndReturn(Status.FailNeedTerrain);
Location current = handler.GetCurrentLocation();
goal.ToAbsolute(current);
var (success, message) = handler.MoveToAStar(goal);
return r.SetAndReturn(success ? Status.Done : Status.Fail, message);
}
}
}

View file

@ -1714,6 +1714,66 @@ 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.
/// Returns a description of the result for UI feedback.
/// </summary>
public (bool success, string message) MoveToAStar(Location goal, long timeoutMs = 5000)
{
lock (locationLock)
{
var ctx = new Pathing.Core.CalculationContext(world);
var finder = new Pathing.Core.AStarPathFinder();
finder.DebugLog = msg => Log.Debug(msg);
int sx = (int)Math.Floor(location.X);
int sy = (int)Math.Floor(location.Y);
int sz = (int)Math.Floor(location.Z);
int gx = (int)Math.Floor(goal.X);
int gy = (int)Math.Floor(goal.Y);
int gz = (int)Math.Floor(goal.Z);
Log.Info($"[Goto] A* search from ({sx},{sy},{sz}) to ({gx},{gy},{gz})...");
using var cts = new CancellationTokenSource();
var result = finder.Calculate(ctx, sx, sy, sz,
new Pathing.Goals.GoalBlock(gx, gy, gz), cts.Token, timeoutMs);
Log.Info($"[Goto] 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)
{
return (false, string.Format(Translations.cmd_goto_failed,
result.NodesExplored, result.ElapsedMs));
}
var queue = new Queue<Location>();
for (int i = 1; i < result.Path.Count; i++)
{
var node = result.Path[i];
queue.Enqueue(new Location(node.X + 0.5, node.Y, node.Z + 0.5));
}
Log.Info($"[Goto] Path waypoints: {queue.Count}");
int logCount = 0;
foreach (var wp in queue)
{
if (logCount < 30 || logCount == queue.Count - 1)
Log.Debug($"[Goto] wp[{logCount}] = ({wp.X:F1},{wp.Y:F1},{wp.Z:F1})");
logCount++;
}
pathTarget = null;
path = queue;
string statusStr = result.Status == Pathing.Core.PathStatus.Partial ? " (partial)" : "";
return (true, string.Format(Translations.cmd_goto_success,
queue.Count, result.NodesExplored, result.ElapsedMs, statusStr));
}
}
/// <summary>
/// Send a chat message or command to the server
/// </summary>

View file

@ -47,6 +47,8 @@ namespace MinecraftClient.Pathing.Core
moves.Add(new MoveClimb(true));
moves.Add(new MoveClimb(false));
moves.Add(new MoveFall());
return [.. moves];
}

View file

@ -0,0 +1,69 @@
using MinecraftClient.Pathing.Core;
namespace MinecraftClient.Pathing.Moves.Impl
{
/// <summary>
/// Straight-down fall at the current X,Z position, for drops greater than MaxFallHeight
/// that MoveDescend won't cover. Scans downward for a safe landing.
/// </summary>
public sealed class MoveFall : IMove
{
public MoveType Type => MoveType.Fall;
public int XOffset => 0;
public int ZOffset => 0;
public bool DynamicY => true;
private readonly int _maxScanDepth;
public MoveFall(int maxScanDepth = 256)
{
_maxScanDepth = maxScanDepth;
}
public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result)
{
if (!ctx.CanWalkThrough(x, y - 1, z))
{
result.SetImpossible();
return;
}
for (int fallDist = 1; fallDist <= _maxScanDepth; fallDist++)
{
int landY = y - fallDist;
if (ctx.CanWalkOn(x, landY - 1, z))
{
if (!ctx.CanWalkThrough(x, landY, z))
{
result.SetImpossible();
return;
}
if (MoveHelper.IsHazardous(ctx.GetMaterial(x, landY - 1, z)))
{
result.SetImpossible();
return;
}
double fallDamageThreshold = 3;
double cost = ActionCosts.FallCost(fallDist);
if (fallDist > fallDamageThreshold)
cost += (fallDist - fallDamageThreshold) * 5.0;
result.Set(x, landY, z, cost);
return;
}
if (!ctx.CanWalkThrough(x, landY, z))
{
result.SetImpossible();
return;
}
}
result.SetImpossible();
}
}
}

View file

@ -3501,6 +3501,33 @@ namespace MinecraftClient {
}
}
/// <summary>
/// Looks up a localized string similar to navigate to a location using A* pathfinding..
/// </summary>
internal static string cmd_goto_desc {
get {
return ResourceManager.GetString("cmd.goto.desc", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Path found: {0} waypoints, {1} nodes explored in {2}ms{3}.
/// </summary>
internal static string cmd_goto_success {
get {
return ResourceManager.GetString("cmd.goto.success", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to No path found ({0} nodes explored in {1}ms).
/// </summary>
internal static string cmd_goto_failed {
get {
return ResourceManager.GetString("cmd.goto.failed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Already following {0}!.
/// </summary>

View file

@ -1243,6 +1243,15 @@ Change EnableEmoji=false in the settings if the display is confusing.</value>
<data name="cmd.exit.desc" xml:space="preserve">
<value>disconnect from the server.</value>
</data>
<data name="cmd.goto.desc" xml:space="preserve">
<value>navigate to a location using A* pathfinding.</value>
</data>
<data name="cmd.goto.success" xml:space="preserve">
<value>Path found: {0} waypoints, {1} nodes explored in {2}ms{3}</value>
</data>
<data name="cmd.goto.failed" xml:space="preserve">
<value>No path found ({0} nodes explored in {1}ms)</value>
</data>
<data name="cmd.follow.already_following" xml:space="preserve">
<value>Already following {0}!</value>
</data>