From 1abab20f170e8f660dde3035c71ee2f7522f1d07 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sat, 11 Apr 2026 01:48:10 +0800 Subject: [PATCH] feat: add Phase 1 core pathfinding architecture Implements the new Baritone-inspired A* pathfinding system: - Core types: PathNode, PathResult, MoveResult, MoveType, ActionCosts - BinaryHeapOpenSet min-heap for A* open set - AStarPathFinder with timeout, cancellation, partial path support - CalculationContext for thread-safe world state snapshots - MoveHelper for block passability checks - IGoal interface + GoalBlock, GoalXZ, GoalNear, GoalComposite - IMove interface + MoveTraverse, MoveDiagonal, MoveAscend, MoveDescend, MoveClimb - /pathfind command for testing the new pathfinder Made-with: Cursor --- MinecraftClient/Commands/Pathfind.cs | 133 ++++++++++++ .../Pathing/Core/AStarPathFinder.cs | 189 ++++++++++++++++++ MinecraftClient/Pathing/Core/ActionCosts.cs | 63 ++++++ .../Pathing/Core/BinaryHeapOpenSet.cs | 96 +++++++++ .../Pathing/Core/CalculationContext.cs | 67 +++++++ MinecraftClient/Pathing/Core/MoveResult.cs | 28 +++ MinecraftClient/Pathing/Core/MoveType.cs | 13 ++ MinecraftClient/Pathing/Core/PathNode.cs | 39 ++++ MinecraftClient/Pathing/Core/PathResult.cs | 30 +++ MinecraftClient/Pathing/Goals/GoalBlock.cs | 42 ++++ .../Pathing/Goals/GoalComposite.cs | 45 +++++ MinecraftClient/Pathing/Goals/GoalNear.cs | 42 ++++ MinecraftClient/Pathing/Goals/GoalXZ.cs | 28 +++ MinecraftClient/Pathing/Goals/IGoal.cs | 8 + MinecraftClient/Pathing/Moves/IMove.cs | 18 ++ .../Pathing/Moves/Impl/MoveAscend.cs | 50 +++++ .../Pathing/Moves/Impl/MoveClimb.cs | 64 ++++++ .../Pathing/Moves/Impl/MoveDescend.cs | 66 ++++++ .../Pathing/Moves/Impl/MoveDiagonal.cs | 54 +++++ .../Pathing/Moves/Impl/MoveTraverse.cs | 54 +++++ MinecraftClient/Pathing/Moves/MoveHelper.cs | 69 +++++++ .../Translations/Translations.Designer.cs | 18 ++ .../Resources/Translations/Translations.resx | 6 + 23 files changed, 1222 insertions(+) create mode 100644 MinecraftClient/Commands/Pathfind.cs create mode 100644 MinecraftClient/Pathing/Core/AStarPathFinder.cs create mode 100644 MinecraftClient/Pathing/Core/ActionCosts.cs create mode 100644 MinecraftClient/Pathing/Core/BinaryHeapOpenSet.cs create mode 100644 MinecraftClient/Pathing/Core/CalculationContext.cs create mode 100644 MinecraftClient/Pathing/Core/MoveResult.cs create mode 100644 MinecraftClient/Pathing/Core/MoveType.cs create mode 100644 MinecraftClient/Pathing/Core/PathNode.cs create mode 100644 MinecraftClient/Pathing/Core/PathResult.cs create mode 100644 MinecraftClient/Pathing/Goals/GoalBlock.cs create mode 100644 MinecraftClient/Pathing/Goals/GoalComposite.cs create mode 100644 MinecraftClient/Pathing/Goals/GoalNear.cs create mode 100644 MinecraftClient/Pathing/Goals/GoalXZ.cs create mode 100644 MinecraftClient/Pathing/Goals/IGoal.cs create mode 100644 MinecraftClient/Pathing/Moves/IMove.cs create mode 100644 MinecraftClient/Pathing/Moves/Impl/MoveAscend.cs create mode 100644 MinecraftClient/Pathing/Moves/Impl/MoveClimb.cs create mode 100644 MinecraftClient/Pathing/Moves/Impl/MoveDescend.cs create mode 100644 MinecraftClient/Pathing/Moves/Impl/MoveDiagonal.cs create mode 100644 MinecraftClient/Pathing/Moves/Impl/MoveTraverse.cs create mode 100644 MinecraftClient/Pathing/Moves/MoveHelper.cs diff --git a/MinecraftClient/Commands/Pathfind.cs b/MinecraftClient/Commands/Pathfind.cs new file mode 100644 index 00000000..ea8c1135 --- /dev/null +++ b/MinecraftClient/Commands/Pathfind.cs @@ -0,0 +1,133 @@ +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 +{ + public class Pathfind : Command + { + public override string CmdName => "pathfind"; + public override string CmdUsage => "pathfind "; + public override string CmdDesc => Translations.cmd_pathfind_desc; + + public override void RegisterCommand(CommandDispatcher dispatcher) + { + dispatcher.Register(l => l.Literal("help") + .Then(l => l.Literal(CmdName) + .Executes(r => GetUsage(r.Source))) + ); + + dispatcher.Register(l => l.Literal(CmdName) + .Then(l => l.Argument("location", MccArguments.Location()) + .Executes(r => DoPathfind(r.Source, MccArguments.GetLocation(r, "location")))) + .Then(l => l.Literal("_help") + .Executes(r => GetUsage(r.Source)) + .Redirect(dispatcher.GetRoot().GetChild("help").GetChild(CmdName))) + ); + } + + private int GetUsage(CmdResult r) + { + return r.SetAndReturn(GetCmdDescTranslated()); + } + + private int DoPathfind(CmdResult r, Location goal) + { + McClient handler = CmdResult.currentHandler!; + if (!handler.GetTerrainEnabled()) + return r.SetAndReturn(Status.FailNeedTerrain); + + 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); + + 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(() => + { + 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 > 0) + { + 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!"); + } + }); + + 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}]: ({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] Old pathfinder failed to plan sub-path to ({node.X},{node.Y},{node.Z}), trying direct teleport"); + 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 horizDistSq = dx * dx + dz * dz; + + handler.Log.Info($"[Pathfind] Arrived near waypoint [{i}], pos=({cur.X:F2},{cur.Y:F2},{cur.Z:F2}), horizDist={Math.Sqrt(horizDistSq):F2}"); + } + + handler.Log.Info("[Pathfind] Path execution complete!"); + } + } +} diff --git a/MinecraftClient/Pathing/Core/AStarPathFinder.cs b/MinecraftClient/Pathing/Core/AStarPathFinder.cs new file mode 100644 index 00000000..05f4dde9 --- /dev/null +++ b/MinecraftClient/Pathing/Core/AStarPathFinder.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using MinecraftClient.Pathing.Goals; +using MinecraftClient.Pathing.Moves; +using MinecraftClient.Pathing.Moves.Impl; + +namespace MinecraftClient.Pathing.Core +{ + public sealed class AStarPathFinder + { + private readonly IMove[] _allMoves; + private readonly int _maxChunkBorderFetch; + + public Action? DebugLog { get; set; } + + public AStarPathFinder(IMove[]? moves = null, int maxChunkBorderFetch = 64) + { + _allMoves = moves ?? BuildDefaultMoves(); + _maxChunkBorderFetch = maxChunkBorderFetch; + } + + public static IMove[] BuildDefaultMoves() + { + var moves = new List(); + + int[] offsets = [1, -1]; + foreach (int dx in offsets) + { + moves.Add(new MoveTraverse(dx, 0)); + moves.Add(new MoveAscend(dx, 0)); + moves.Add(new MoveDescend(dx, 0)); + } + foreach (int dz in offsets) + { + moves.Add(new MoveTraverse(0, dz)); + moves.Add(new MoveAscend(0, dz)); + moves.Add(new MoveDescend(0, dz)); + } + + moves.Add(new MoveDiagonal(1, 1)); + moves.Add(new MoveDiagonal(1, -1)); + moves.Add(new MoveDiagonal(-1, 1)); + moves.Add(new MoveDiagonal(-1, -1)); + + moves.Add(new MoveClimb(true)); + moves.Add(new MoveClimb(false)); + + return [.. moves]; + } + + public PathResult Calculate( + CalculationContext ctx, + int startX, int startY, int startZ, + IGoal goal, + CancellationToken ct, + long timeoutMs = 5000) + { + var sw = Stopwatch.StartNew(); + var openSet = new BinaryHeapOpenSet(4096); + var nodeMap = new Dictionary(4096); + + var startNode = new PathNode(startX, startY, startZ) + { + GCost = 0, + HCost = goal.Heuristic(startX, startY, startZ), + IsOpen = true + }; + openSet.Insert(startNode); + nodeMap[startNode.PackedPosition] = startNode; + + int nodesExplored = 0; + int unloadedChunkHits = 0; + PathNode? bestPartialNode = startNode; + double bestPartialScore = startNode.HCost + startNode.GCost * 0.5; + MoveResult moveResult = default; + + DebugLog?.Invoke($"[A*] Start ({startX},{startY},{startZ}), goal={goal}"); + + while (openSet.Count > 0) + { + if (ct.IsCancellationRequested) + { + DebugLog?.Invoke($"[A*] Cancelled after {nodesExplored} nodes, {sw.ElapsedMilliseconds}ms"); + break; + } + + if (sw.ElapsedMilliseconds > timeoutMs) + { + DebugLog?.Invoke($"[A*] Timeout ({timeoutMs}ms) after {nodesExplored} nodes"); + break; + } + + var current = openSet.RemoveMin(); + current.IsClosed = true; + nodesExplored++; + + if (goal.IsInGoal(current.X, current.Y, current.Z)) + { + DebugLog?.Invoke($"[A*] Goal reached! {nodesExplored} nodes, {sw.ElapsedMilliseconds}ms"); + var path = ReconstructPath(current); + return new PathResult(PathStatus.Success, path, nodesExplored, sw.ElapsedMilliseconds); + } + + foreach (var move in _allMoves) + { + moveResult.Cost = 0; + move.Calculate(ctx, current.X, current.Y, current.Z, ref moveResult); + + if (moveResult.IsImpossible) + continue; + + int nx = moveResult.DestX; + int ny = moveResult.DestY; + int nz = moveResult.DestZ; + + if (!ctx.IsChunkLoaded(nx, nz)) + { + unloadedChunkHits++; + if (unloadedChunkHits > _maxChunkBorderFetch) + continue; + } + + double tentativeG = current.GCost + moveResult.Cost; + long packed = PathNode.Pack(nx, ny, nz); + + if (nodeMap.TryGetValue(packed, out var neighbor)) + { + if (neighbor.IsClosed) + continue; + if (tentativeG >= neighbor.GCost) + continue; + + neighbor.GCost = tentativeG; + neighbor.Parent = current; + neighbor.MoveUsed = move.Type; + if (neighbor.IsOpen) + openSet.Update(neighbor); + } + else + { + neighbor = new PathNode(nx, ny, nz) + { + GCost = tentativeG, + HCost = goal.Heuristic(nx, ny, nz), + Parent = current, + MoveUsed = move.Type, + IsOpen = true + }; + nodeMap[packed] = neighbor; + openSet.Insert(neighbor); + } + + double partialScore = neighbor.HCost + neighbor.GCost * 0.5; + if (partialScore < bestPartialScore) + { + bestPartialScore = partialScore; + bestPartialNode = neighbor; + } + } + } + + if (bestPartialNode is not null && bestPartialNode != startNode) + { + DebugLog?.Invoke($"[A*] Partial path to ({bestPartialNode.X},{bestPartialNode.Y},{bestPartialNode.Z}), " + + $"{nodesExplored} nodes, {sw.ElapsedMilliseconds}ms"); + var path = ReconstructPath(bestPartialNode); + return new PathResult(PathStatus.Partial, path, nodesExplored, sw.ElapsedMilliseconds); + } + + DebugLog?.Invoke($"[A*] Failed, {nodesExplored} nodes, {sw.ElapsedMilliseconds}ms"); + return PathResult.Fail(nodesExplored, sw.ElapsedMilliseconds); + } + + private static List ReconstructPath(PathNode end) + { + var path = new List(); + var current = end; + while (current is not null) + { + path.Add(current); + current = current.Parent; + } + path.Reverse(); + return path; + } + } +} diff --git a/MinecraftClient/Pathing/Core/ActionCosts.cs b/MinecraftClient/Pathing/Core/ActionCosts.cs new file mode 100644 index 00000000..544413fe --- /dev/null +++ b/MinecraftClient/Pathing/Core/ActionCosts.cs @@ -0,0 +1,63 @@ +namespace MinecraftClient.Pathing.Core +{ + /// + /// All pathfinding movement costs in ticks, derived from vanilla walking/sprinting speeds. + /// Mirrors Baritone's ActionCosts design. + /// + public static class ActionCosts + { + public const double WalkOneBlock = 20.0 / 4.317; + public const double SprintOneBlock = 20.0 / 5.612; + public const double SneakOneBlock = 20.0 / 1.3; + public const double LadderUpOne = 20.0 / 2.35; + public const double LadderDownOne = 20.0 / 3.0; + public const double WalkOffBlock = WalkOneBlock * 0.8; + public const double SprintMultiplier = SprintOneBlock / WalkOneBlock; + public const double DiagonalMultiplier = 1.4142135623730951; + public const double CostInf = 1_000_000; + + public const double JumpPenalty = 2.0; + + public static readonly double[] FallNBlocksCost = BuildFallTable(257); + + private static double[] BuildFallTable(int maxBlocks) + { + var table = new double[maxBlocks]; + table[0] = 0; + + double velocity = 0; + double distance = 0; + int ticks = 0; + int blockIndex = 1; + + while (blockIndex < maxBlocks) + { + velocity += 0.08; + velocity *= 0.98; + distance += velocity; + ticks++; + + while (blockIndex < maxBlocks && distance >= blockIndex) + { + table[blockIndex] = ticks; + blockIndex++; + } + + if (ticks > 10000) + break; + } + + for (int i = blockIndex; i < maxBlocks; i++) + table[i] = CostInf; + + return table; + } + + public static double FallCost(int blocks) + { + if (blocks < 0 || blocks >= FallNBlocksCost.Length) + return CostInf; + return FallNBlocksCost[blocks]; + } + } +} diff --git a/MinecraftClient/Pathing/Core/BinaryHeapOpenSet.cs b/MinecraftClient/Pathing/Core/BinaryHeapOpenSet.cs new file mode 100644 index 00000000..2ea2cd6a --- /dev/null +++ b/MinecraftClient/Pathing/Core/BinaryHeapOpenSet.cs @@ -0,0 +1,96 @@ +using System; + +namespace MinecraftClient.Pathing.Core +{ + /// + /// Min-heap of PathNodes ordered by FCost, used as the A* open set. + /// + public sealed class BinaryHeapOpenSet + { + private PathNode[] _heap; + private int _size; + + public int Count => _size; + + public BinaryHeapOpenSet(int initialCapacity = 1024) + { + _heap = new PathNode[initialCapacity]; + _size = 0; + } + + public void Insert(PathNode node) + { + if (_size == _heap.Length) + Array.Resize(ref _heap, _heap.Length * 2); + + node.HeapIndex = _size; + _heap[_size] = node; + _size++; + SiftUp(_size - 1); + } + + public PathNode RemoveMin() + { + var min = _heap[0]; + _size--; + if (_size > 0) + { + _heap[0] = _heap[_size]; + _heap[0].HeapIndex = 0; + SiftDown(0); + } + _heap[_size] = null!; + min.IsOpen = false; + return min; + } + + public void Update(PathNode node) + { + SiftUp(node.HeapIndex); + } + + private void SiftUp(int i) + { + var node = _heap[i]; + while (i > 0) + { + int parent = (i - 1) >> 1; + if (Compare(node, _heap[parent]) >= 0) + break; + _heap[i] = _heap[parent]; + _heap[i].HeapIndex = i; + i = parent; + } + _heap[i] = node; + node.HeapIndex = i; + } + + private void SiftDown(int i) + { + var node = _heap[i]; + int half = _size >> 1; + while (i < half) + { + int left = (i << 1) + 1; + int right = left + 1; + int best = left; + if (right < _size && Compare(_heap[right], _heap[left]) < 0) + best = right; + if (Compare(node, _heap[best]) <= 0) + break; + _heap[i] = _heap[best]; + _heap[i].HeapIndex = i; + i = best; + } + _heap[i] = node; + node.HeapIndex = i; + } + + private static int Compare(PathNode a, PathNode b) + { + int cmp = a.FCost.CompareTo(b.FCost); + if (cmp != 0) return cmp; + return a.HCost.CompareTo(b.HCost); + } + } +} diff --git a/MinecraftClient/Pathing/Core/CalculationContext.cs b/MinecraftClient/Pathing/Core/CalculationContext.cs new file mode 100644 index 00000000..8728d7b2 --- /dev/null +++ b/MinecraftClient/Pathing/Core/CalculationContext.cs @@ -0,0 +1,67 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Moves; + +namespace MinecraftClient.Pathing.Core +{ + /// + /// Thread-safe snapshot of world state and player capabilities for path planning. + /// Created once at the start of a search; all move calculations read from this. + /// + public sealed class CalculationContext + { + public World World { get; } + public bool CanSprint { get; } + public bool AllowParkour { get; } + public bool AllowParkourAscend { get; } + public bool AllowDiagonalDescend { get; } + public int MaxFallHeight { get; } + public double JumpPenalty { get; } + public double WalkCost { get; } + public double SprintCost { get; } + public double SneakCost { get; } + + public CalculationContext( + World world, + bool canSprint = true, + bool allowParkour = false, + bool allowParkourAscend = false, + bool allowDiagonalDescend = true, + int maxFallHeight = 3, + double jumpPenalty = ActionCosts.JumpPenalty) + { + World = world; + CanSprint = canSprint; + AllowParkour = allowParkour; + AllowParkourAscend = allowParkourAscend; + AllowDiagonalDescend = allowDiagonalDescend; + MaxFallHeight = maxFallHeight; + JumpPenalty = jumpPenalty; + WalkCost = ActionCosts.WalkOneBlock; + SprintCost = CanSprint ? ActionCosts.SprintOneBlock : ActionCosts.WalkOneBlock; + SneakCost = ActionCosts.SneakOneBlock; + } + + public Block GetBlock(int x, int y, int z) + => World.GetBlock(new Location(x, y, z)); + + public Material GetMaterial(int x, int y, int z) + => GetBlock(x, y, z).Type; + + public bool CanWalkThrough(int x, int y, int z) + => MoveHelper.CanWalkThrough(this, x, y, z); + + public bool CanWalkOn(int x, int y, int z) + => MoveHelper.CanWalkOn(this, x, y, z); + + public bool IsFullyPassable(int x, int y, int z) + => MoveHelper.IsFullyPassable(this, x, y, z); + + public bool IsChunkLoaded(int x, int z) + { + int cx = x >> 4; + int cz = z >> 4; + var col = World[cx, cz]; + return col is not null && col.FullyLoaded; + } + } +} diff --git a/MinecraftClient/Pathing/Core/MoveResult.cs b/MinecraftClient/Pathing/Core/MoveResult.cs new file mode 100644 index 00000000..59045b48 --- /dev/null +++ b/MinecraftClient/Pathing/Core/MoveResult.cs @@ -0,0 +1,28 @@ +namespace MinecraftClient.Pathing.Core +{ + /// + /// Result of an IMove.Calculate() call. Mutable struct passed by ref for zero-alloc hot path. + /// + public struct MoveResult + { + public int DestX; + public int DestY; + public int DestZ; + public double Cost; + + public void Set(int x, int y, int z, double cost) + { + DestX = x; + DestY = y; + DestZ = z; + Cost = cost; + } + + public void SetImpossible() + { + Cost = ActionCosts.CostInf; + } + + public readonly bool IsImpossible => Cost >= ActionCosts.CostInf; + } +} diff --git a/MinecraftClient/Pathing/Core/MoveType.cs b/MinecraftClient/Pathing/Core/MoveType.cs new file mode 100644 index 00000000..3d632012 --- /dev/null +++ b/MinecraftClient/Pathing/Core/MoveType.cs @@ -0,0 +1,13 @@ +namespace MinecraftClient.Pathing.Core +{ + public enum MoveType + { + Traverse, + Diagonal, + Ascend, + Descend, + Fall, + Climb, + Parkour + } +} diff --git a/MinecraftClient/Pathing/Core/PathNode.cs b/MinecraftClient/Pathing/Core/PathNode.cs new file mode 100644 index 00000000..f8f0d25b --- /dev/null +++ b/MinecraftClient/Pathing/Core/PathNode.cs @@ -0,0 +1,39 @@ +namespace MinecraftClient.Pathing.Core +{ + /// + /// A* search node. Stored in the open/closed sets during pathfinding. + /// + public sealed class PathNode + { + public readonly int X; + public readonly int Y; + public readonly int Z; + + public double GCost; + public double HCost; + public double FCost => GCost + HCost; + + public PathNode? Parent; + public MoveType MoveUsed; + + public int HeapIndex; + public bool IsOpen; + public bool IsClosed; + + public PathNode(int x, int y, int z) + { + X = x; + Y = y; + Z = z; + } + + public long PackedPosition => Pack(X, Y, Z); + + public static long Pack(int x, int y, int z) + { + return ((long)(x + 30_000_000) << 36) + | ((long)(z + 30_000_000) << 12) + | (long)((y + 64) & 0xFFF); + } + } +} diff --git a/MinecraftClient/Pathing/Core/PathResult.cs b/MinecraftClient/Pathing/Core/PathResult.cs new file mode 100644 index 00000000..9d9ba7ee --- /dev/null +++ b/MinecraftClient/Pathing/Core/PathResult.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; + +namespace MinecraftClient.Pathing.Core +{ + public enum PathStatus + { + Success, + Partial, + Failed + } + + public sealed class PathResult + { + public PathStatus Status { get; } + public IReadOnlyList Path { get; } + public int NodesExplored { get; } + public long ElapsedMs { get; } + + public PathResult(PathStatus status, IReadOnlyList path, int nodesExplored, long elapsedMs) + { + Status = status; + Path = path; + NodesExplored = nodesExplored; + ElapsedMs = elapsedMs; + } + + public static PathResult Fail(int nodesExplored, long elapsedMs) + => new(PathStatus.Failed, [], nodesExplored, elapsedMs); + } +} diff --git a/MinecraftClient/Pathing/Goals/GoalBlock.cs b/MinecraftClient/Pathing/Goals/GoalBlock.cs new file mode 100644 index 00000000..55cbdf7f --- /dev/null +++ b/MinecraftClient/Pathing/Goals/GoalBlock.cs @@ -0,0 +1,42 @@ +using System; + +namespace MinecraftClient.Pathing.Goals +{ + public sealed class GoalBlock : IGoal + { + public int X { get; } + public int Y { get; } + public int Z { get; } + + public GoalBlock(int x, int y, int z) + { + X = x; + Y = y; + Z = z; + } + + public bool IsInGoal(int x, int y, int z) + => x == X && y == Y && z == Z; + + public double Heuristic(int x, int y, int z) + { + int dx = Math.Abs(x - X); + int dy = Math.Abs(y - Y); + int dz = Math.Abs(z - Z); + return DistanceHeuristic(dx, dy, dz); + } + + internal static double DistanceHeuristic(int dx, int dy, int dz) + { + int horizontal = Math.Max(dx, dz); + int diagonal = Math.Min(dx, dz); + int straight = horizontal - diagonal; + double cost = diagonal * Core.ActionCosts.SprintOneBlock * Core.ActionCosts.DiagonalMultiplier + + straight * Core.ActionCosts.SprintOneBlock + + Math.Abs(dy) * Core.ActionCosts.SprintOneBlock; + return cost; + } + + public override string ToString() => $"GoalBlock({X}, {Y}, {Z})"; + } +} diff --git a/MinecraftClient/Pathing/Goals/GoalComposite.cs b/MinecraftClient/Pathing/Goals/GoalComposite.cs new file mode 100644 index 00000000..68dd5d50 --- /dev/null +++ b/MinecraftClient/Pathing/Goals/GoalComposite.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; + +namespace MinecraftClient.Pathing.Goals +{ + public sealed class GoalComposite : IGoal + { + private readonly IGoal[] _goals; + + public GoalComposite(params IGoal[] goals) + { + ArgumentNullException.ThrowIfNull(goals); + _goals = goals; + } + + public GoalComposite(IEnumerable goals) + { + ArgumentNullException.ThrowIfNull(goals); + _goals = goals is IGoal[] arr ? arr : [.. goals]; + } + + public bool IsInGoal(int x, int y, int z) + { + foreach (var g in _goals) + { + if (g.IsInGoal(x, y, z)) + return true; + } + return false; + } + + public double Heuristic(int x, int y, int z) + { + double min = double.MaxValue; + foreach (var g in _goals) + { + double h = g.Heuristic(x, y, z); + if (h < min) min = h; + } + return min; + } + + public override string ToString() => $"GoalComposite({_goals.Length} goals)"; + } +} diff --git a/MinecraftClient/Pathing/Goals/GoalNear.cs b/MinecraftClient/Pathing/Goals/GoalNear.cs new file mode 100644 index 00000000..1fbd3d21 --- /dev/null +++ b/MinecraftClient/Pathing/Goals/GoalNear.cs @@ -0,0 +1,42 @@ +using System; + +namespace MinecraftClient.Pathing.Goals +{ + public sealed class GoalNear : IGoal + { + public int X { get; } + public int Y { get; } + public int Z { get; } + public int Range { get; } + private readonly int _rangeSq; + + public GoalNear(int x, int y, int z, int range) + { + X = x; + Y = y; + Z = z; + Range = range; + _rangeSq = range * range; + } + + public bool IsInGoal(int x, int y, int z) + { + int dx = x - X; + int dy = y - Y; + int dz = z - Z; + return dx * dx + dy * dy + dz * dz <= _rangeSq; + } + + public double Heuristic(int x, int y, int z) + { + int dx = Math.Abs(x - X); + int dy = Math.Abs(y - Y); + int dz = Math.Abs(z - Z); + double h = GoalBlock.DistanceHeuristic(dx, dy, dz); + double reduction = Range * Core.ActionCosts.SprintOneBlock; + return Math.Max(0, h - reduction); + } + + public override string ToString() => $"GoalNear({X}, {Y}, {Z}, range={Range})"; + } +} diff --git a/MinecraftClient/Pathing/Goals/GoalXZ.cs b/MinecraftClient/Pathing/Goals/GoalXZ.cs new file mode 100644 index 00000000..22a4873f --- /dev/null +++ b/MinecraftClient/Pathing/Goals/GoalXZ.cs @@ -0,0 +1,28 @@ +using System; + +namespace MinecraftClient.Pathing.Goals +{ + public sealed class GoalXZ : IGoal + { + public int X { get; } + public int Z { get; } + + public GoalXZ(int x, int z) + { + X = x; + Z = z; + } + + public bool IsInGoal(int x, int y, int z) + => x == X && z == Z; + + public double Heuristic(int x, int y, int z) + { + int dx = Math.Abs(x - X); + int dz = Math.Abs(z - Z); + return GoalBlock.DistanceHeuristic(dx, 0, dz); + } + + public override string ToString() => $"GoalXZ({X}, {Z})"; + } +} diff --git a/MinecraftClient/Pathing/Goals/IGoal.cs b/MinecraftClient/Pathing/Goals/IGoal.cs new file mode 100644 index 00000000..13cada06 --- /dev/null +++ b/MinecraftClient/Pathing/Goals/IGoal.cs @@ -0,0 +1,8 @@ +namespace MinecraftClient.Pathing.Goals +{ + public interface IGoal + { + bool IsInGoal(int x, int y, int z); + double Heuristic(int x, int y, int z); + } +} diff --git a/MinecraftClient/Pathing/Moves/IMove.cs b/MinecraftClient/Pathing/Moves/IMove.cs new file mode 100644 index 00000000..69e9e107 --- /dev/null +++ b/MinecraftClient/Pathing/Moves/IMove.cs @@ -0,0 +1,18 @@ +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Moves +{ + /// + /// Represents one type of movement action for path planning. + /// Each implementation defines its spatial check pattern and cost model. + /// + public interface IMove + { + MoveType Type { get; } + int XOffset { get; } + int ZOffset { get; } + bool DynamicY { get; } + + void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result); + } +} diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveAscend.cs b/MinecraftClient/Pathing/Moves/Impl/MoveAscend.cs new file mode 100644 index 00000000..53d16f14 --- /dev/null +++ b/MinecraftClient/Pathing/Moves/Impl/MoveAscend.cs @@ -0,0 +1,50 @@ +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Moves.Impl +{ + /// + /// Jump up 1 block in a cardinal direction. + /// Requires: headroom at (x, y+2, z), body space at dest (y+1, y+2), ground at dest (y). + /// + public sealed class MoveAscend : IMove + { + public MoveType Type => MoveType.Ascend; + public int XOffset { get; } + public int ZOffset { get; } + public bool DynamicY => false; + + public MoveAscend(int xOffset, int zOffset) + { + XOffset = xOffset; + ZOffset = zOffset; + } + + public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result) + { + int destX = x + XOffset; + int destZ = z + ZOffset; + int destY = y + 1; + + if (!ctx.CanWalkThrough(x, y + 2, z)) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanWalkThrough(destX, destY, destZ) || !ctx.CanWalkThrough(destX, destY + 1, destZ)) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanWalkOn(destX, y, destZ)) + { + result.SetImpossible(); + return; + } + + double cost = ctx.SprintCost + ctx.JumpPenalty; + result.Set(destX, destY, destZ, cost); + } + } +} diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveClimb.cs b/MinecraftClient/Pathing/Moves/Impl/MoveClimb.cs new file mode 100644 index 00000000..1952b74d --- /dev/null +++ b/MinecraftClient/Pathing/Moves/Impl/MoveClimb.cs @@ -0,0 +1,64 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Moves.Impl +{ + /// + /// Climb up or down a ladder/vine at the current X,Z position. + /// + public sealed class MoveClimb : IMove + { + public MoveType Type => MoveType.Climb; + public int XOffset => 0; + public int ZOffset => 0; + public bool DynamicY => false; + + private readonly bool _up; + + public MoveClimb(bool up) + { + _up = up; + } + + public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result) + { + var currentMat = ctx.GetMaterial(x, y, z); + if (!MoveHelper.IsClimbable(currentMat)) + { + result.SetImpossible(); + return; + } + + if (_up) + { + int destY = y + 1; + if (!ctx.CanWalkThrough(x, destY + 1, z)) + { + result.SetImpossible(); + return; + } + + var aboveMat = ctx.GetMaterial(x, destY, z); + if (MoveHelper.IsClimbable(aboveMat) || !ctx.GetMaterial(x, destY, z).IsSolid()) + { + result.Set(x, destY, z, ActionCosts.LadderUpOne); + return; + } + + result.SetImpossible(); + } + else + { + int destY = y - 1; + var belowMat = ctx.GetMaterial(x, destY, z); + if (MoveHelper.IsClimbable(belowMat) || !belowMat.IsSolid()) + { + result.Set(x, destY, z, ActionCosts.LadderDownOne); + return; + } + + result.SetImpossible(); + } + } + } +} diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveDescend.cs b/MinecraftClient/Pathing/Moves/Impl/MoveDescend.cs new file mode 100644 index 00000000..3dc47fd7 --- /dev/null +++ b/MinecraftClient/Pathing/Moves/Impl/MoveDescend.cs @@ -0,0 +1,66 @@ +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Moves.Impl +{ + /// + /// Walk off a ledge and drop 1-N blocks in a cardinal direction. + /// Scans downward for a landing spot within MaxFallHeight. + /// + public sealed class MoveDescend : IMove + { + public MoveType Type => MoveType.Descend; + public int XOffset { get; } + public int ZOffset { get; } + public bool DynamicY => true; + + public MoveDescend(int xOffset, int zOffset) + { + XOffset = xOffset; + ZOffset = zOffset; + } + + public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result) + { + int destX = x + XOffset; + int destZ = z + ZOffset; + + if (!ctx.CanWalkThrough(destX, y, destZ) || !ctx.CanWalkThrough(destX, y + 1, destZ)) + { + result.SetImpossible(); + return; + } + + for (int fallDist = 1; fallDist <= ctx.MaxFallHeight; fallDist++) + { + int landY = y - fallDist; + + if (ctx.CanWalkOn(destX, landY - 1, destZ)) + { + if (!ctx.CanWalkThrough(destX, landY, destZ)) + { + result.SetImpossible(); + return; + } + + double cost = ActionCosts.WalkOffBlock + ActionCosts.FallCost(fallDist); + if (MoveHelper.IsHazardous(ctx.GetMaterial(destX, landY - 1, destZ))) + { + result.SetImpossible(); + return; + } + + result.Set(destX, landY, destZ, cost); + return; + } + + if (!ctx.CanWalkThrough(destX, landY, destZ)) + { + result.SetImpossible(); + return; + } + } + + result.SetImpossible(); + } + } +} diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveDiagonal.cs b/MinecraftClient/Pathing/Moves/Impl/MoveDiagonal.cs new file mode 100644 index 00000000..70e78d77 --- /dev/null +++ b/MinecraftClient/Pathing/Moves/Impl/MoveDiagonal.cs @@ -0,0 +1,54 @@ +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Moves.Impl +{ + /// + /// Diagonal walk (1 block in both X and Z, same Y). + /// Checks both intermediate cardinal columns for clearance. + /// + public sealed class MoveDiagonal : IMove + { + public MoveType Type => MoveType.Diagonal; + public int XOffset { get; } + public int ZOffset { get; } + public bool DynamicY => false; + + public MoveDiagonal(int xOffset, int zOffset) + { + XOffset = xOffset; + ZOffset = zOffset; + } + + public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result) + { + int destX = x + XOffset; + int destZ = z + ZOffset; + + if (!ctx.CanWalkThrough(destX, y, destZ) || !ctx.CanWalkThrough(destX, y + 1, destZ)) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanWalkOn(destX, y - 1, destZ)) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanWalkThrough(x + XOffset, y, z) || !ctx.CanWalkThrough(x + XOffset, y + 1, z)) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanWalkThrough(x, y, z + ZOffset) || !ctx.CanWalkThrough(x, y + 1, z + ZOffset)) + { + result.SetImpossible(); + return; + } + + result.Set(destX, y, destZ, ctx.SprintCost * ActionCosts.DiagonalMultiplier); + } + } +} diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveTraverse.cs b/MinecraftClient/Pathing/Moves/Impl/MoveTraverse.cs new file mode 100644 index 00000000..590503af --- /dev/null +++ b/MinecraftClient/Pathing/Moves/Impl/MoveTraverse.cs @@ -0,0 +1,54 @@ +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Moves.Impl +{ + /// + /// Flat cardinal walk (1 block in +/-X or +/-Z, same Y). + /// Checks body+head passable and ground below destination. + /// + public sealed class MoveTraverse : IMove + { + public MoveType Type => MoveType.Traverse; + public int XOffset { get; } + public int ZOffset { get; } + public bool DynamicY => false; + + public MoveTraverse(int xOffset, int zOffset) + { + XOffset = xOffset; + ZOffset = zOffset; + } + + public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result) + { + int destX = x + XOffset; + int destZ = z + ZOffset; + + if (!ctx.CanWalkThrough(destX, y, destZ)) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanWalkThrough(destX, y + 1, destZ)) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanWalkOn(destX, y - 1, destZ)) + { + result.SetImpossible(); + return; + } + + double cost = ctx.SprintCost; + + var destFloorMat = ctx.GetMaterial(destX, y - 1, destZ); + if (destFloorMat == Mapping.Material.SoulSand) + cost *= 1.0 / Physics.PhysicsConsts.SoulSandSpeedFactor; + + result.Set(destX, y, destZ, cost); + } + } +} diff --git a/MinecraftClient/Pathing/Moves/MoveHelper.cs b/MinecraftClient/Pathing/Moves/MoveHelper.cs new file mode 100644 index 00000000..8bb6108f --- /dev/null +++ b/MinecraftClient/Pathing/Moves/MoveHelper.cs @@ -0,0 +1,69 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Moves +{ + /// + /// Block passability checks for path planning. + /// Uses Material-level checks initially; designed to allow future BlockShapes upgrade. + /// + public static class MoveHelper + { + /// + /// Can a player's body/head occupy this block position? (air, open door, tall grass, etc.) + /// + public static bool CanWalkThrough(CalculationContext ctx, int x, int y, int z) + { + Material mat = ctx.GetMaterial(x, y, z); + if (mat == Material.Air || mat == Material.CaveAir || mat == Material.VoidAir) + return true; + if (mat.IsLiquid()) + return false; + if (mat.IsSolid()) + return false; + if (mat.CanHarmPlayers()) + return false; + return true; + } + + /// + /// Can a player stand on top of this block? (solid upper surface) + /// + public static bool CanWalkOn(CalculationContext ctx, int x, int y, int z) + { + Material mat = ctx.GetMaterial(x, y, z); + if (mat == Material.Air || mat == Material.CaveAir || mat == Material.VoidAir) + return false; + if (mat.IsLiquid()) + return false; + if (mat.CanHarmPlayers()) + return false; + return mat.IsSolid(); + } + + /// + /// Is this block completely passable with no slowdown or interaction? + /// Stricter than CanWalkThrough -- excludes water, cobwebs, etc. + /// + public static bool IsFullyPassable(CalculationContext ctx, int x, int y, int z) + { + Material mat = ctx.GetMaterial(x, y, z); + return mat == Material.Air || mat == Material.CaveAir || mat == Material.VoidAir; + } + + public static bool IsClimbable(Material mat) + { + return mat.CanBeClimbedOn(); + } + + public static bool IsHazardous(Material mat) + { + return mat.CanHarmPlayers(); + } + + public static bool IsWater(Material mat) + { + return mat == Material.Water; + } + } +} diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs index b6e454cd..b5407c84 100644 --- a/MinecraftClient/Resources/Translations/Translations.Designer.cs +++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs @@ -4585,6 +4585,24 @@ namespace MinecraftClient { } } + /// + /// Looks up a localized string similar to Use new A* pathfinding to navigate to a location.. + /// + internal static string cmd_pathfind_desc { + get { + return ResourceManager.GetString("cmd.pathfind.desc", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Pathfinding to ({0}, {1}, {2}).... + /// + internal static string cmd_pathfind_started { + get { + return ResourceManager.GetString("cmd.pathfind.started", resourceCulture); + } + } + /// /// Looks up a localized string similar to restart and reconnect to the server.. /// diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx index 2e9da34a..e4d96ef7 100644 --- a/MinecraftClient/Resources/Translations/Translations.resx +++ b/MinecraftClient/Resources/Translations/Translations.resx @@ -1538,6 +1538,12 @@ You can use "/chunk status {0:0.0} {1:0.0} {2:0.0}" to check the chunk loading s Walking from {1} to {0} + + Use new A* pathfinding to navigate to a location. + + + Pathfinding to ({0}, {1}, {2})... + restart and reconnect to the server.