From 77c5f881680d51ba7c70538bb8579de3c4a617ec Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sat, 11 Apr 2026 02:27:10 +0800 Subject: [PATCH] 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 + UpdatePathfindingInput) - Add translation entries for goto command Made-with: Cursor --- MinecraftClient/Commands/Goto.cs | 50 ++++++++++++++ MinecraftClient/McClient.cs | 60 ++++++++++++++++ .../Pathing/Core/AStarPathFinder.cs | 2 + .../Pathing/Moves/Impl/MoveFall.cs | 69 +++++++++++++++++++ .../Translations/Translations.Designer.cs | 27 ++++++++ .../Resources/Translations/Translations.resx | 9 +++ 6 files changed, 217 insertions(+) create mode 100644 MinecraftClient/Commands/Goto.cs create mode 100644 MinecraftClient/Pathing/Moves/Impl/MoveFall.cs diff --git a/MinecraftClient/Commands/Goto.cs b/MinecraftClient/Commands/Goto.cs new file mode 100644 index 00000000..18cf7d0f --- /dev/null +++ b/MinecraftClient/Commands/Goto.cs @@ -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 "; + public override string CmdDesc => Translations.cmd_goto_desc; + + public override void RegisterCommand(CommandDispatcher 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); + } + } +} diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index 115c7a63..9c3f9751 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -1714,6 +1714,66 @@ namespace MinecraftClient } } + /// + /// 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. + /// + 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(); + 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)); + } + } + /// /// Send a chat message or command to the server /// diff --git a/MinecraftClient/Pathing/Core/AStarPathFinder.cs b/MinecraftClient/Pathing/Core/AStarPathFinder.cs index 05f4dde9..a4c68b93 100644 --- a/MinecraftClient/Pathing/Core/AStarPathFinder.cs +++ b/MinecraftClient/Pathing/Core/AStarPathFinder.cs @@ -47,6 +47,8 @@ namespace MinecraftClient.Pathing.Core moves.Add(new MoveClimb(true)); moves.Add(new MoveClimb(false)); + moves.Add(new MoveFall()); + return [.. moves]; } diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveFall.cs b/MinecraftClient/Pathing/Moves/Impl/MoveFall.cs new file mode 100644 index 00000000..e3fd74c4 --- /dev/null +++ b/MinecraftClient/Pathing/Moves/Impl/MoveFall.cs @@ -0,0 +1,69 @@ +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Moves.Impl +{ + /// + /// 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. + /// + 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(); + } + } +} diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs index b5407c84..64e955ba 100644 --- a/MinecraftClient/Resources/Translations/Translations.Designer.cs +++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs @@ -3501,6 +3501,33 @@ namespace MinecraftClient { } } + /// + /// Looks up a localized string similar to navigate to a location using A* pathfinding.. + /// + internal static string cmd_goto_desc { + get { + return ResourceManager.GetString("cmd.goto.desc", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Path found: {0} waypoints, {1} nodes explored in {2}ms{3}. + /// + internal static string cmd_goto_success { + get { + return ResourceManager.GetString("cmd.goto.success", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No path found ({0} nodes explored in {1}ms). + /// + internal static string cmd_goto_failed { + get { + return ResourceManager.GetString("cmd.goto.failed", resourceCulture); + } + } + /// /// Looks up a localized string similar to Already following {0}!. /// diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx index e4d96ef7..abe2cc42 100644 --- a/MinecraftClient/Resources/Translations/Translations.resx +++ b/MinecraftClient/Resources/Translations/Translations.resx @@ -1243,6 +1243,15 @@ Change EnableEmoji=false in the settings if the display is confusing. disconnect from the server. + + navigate to a location using A* pathfinding. + + + Path found: {0} waypoints, {1} nodes explored in {2}ms{3} + + + No path found ({0} nodes explored in {1}ms) + Already following {0}!