feat: add parkour moves and template-based path execution system

Phase 2.2: MoveParkour for sprint-jump across 1-2 block gaps (distance 2-3)
and ascending parkour (distance 2, +1Y). Registered in BuildDefaultMoves
with CalculationContext.AllowParkour gating.

Phase 3.1-3.2: Template execution engine replacing the waypoint queue system.
- IActionTemplate interface with per-tick state machine pattern
- Templates: Walk, Ascend, Descend, Climb, Fall, SprintJump
- ActionTemplateFactory maps MoveType to the correct template
- PathExecutor drives sequential template execution with logging
- PathSegmentManager handles replanning on failure (up to 5 retries)
- McClient integration: MoveToAStar now creates PathSegmentManager,
  UpdatePathfindingInput delegates to it, CancelMovement/ClientIsMoving
  updated for both old and new systems.

Tested on 1.21.11: straight walk, zigzag maze, stair ascent,
1-gap and 2-gap sprint jumps all pass.

Made-with: Cursor
This commit is contained in:
BruceChen 2026-04-11 12:46:38 +08:00
parent 2d4f1fa677
commit 4b49135107
15 changed files with 833 additions and 26 deletions

View file

@ -80,6 +80,7 @@ namespace MinecraftClient
private readonly MovementInput physicsInput = new();
private bool physicsInitialized = false;
private Location? pathTarget; // Current waypoint for physics-driven pathfinding
private Pathing.Execution.PathSegmentManager? pathSegmentManager;
public enum MovementType { Sneak, Walk, Sprint }
private int sequenceId; // User for player block synchronization (Aka. digging, placing blocks, etc..)
private bool CanSendMessage = false;
@ -1723,7 +1724,8 @@ namespace MinecraftClient
{
lock (locationLock)
{
var ctx = new Pathing.Core.CalculationContext(world);
var ctx = new Pathing.Core.CalculationContext(world,
allowParkour: true, allowParkourAscend: true);
var finder = new Pathing.Core.AStarPathFinder();
finder.DebugLog = msg => Log.Debug(msg);
@ -1731,7 +1733,6 @@ namespace MinecraftClient
int sy = (int)Math.Floor(location.Y);
int sz = (int)Math.Floor(location.Z);
// If floored Y lands inside a solid block (e.g. player on top of it), step up
if (!ctx.CanWalkThrough(sx, sy, sz) && ctx.CanWalkThrough(sx, sy + 1, sz))
sy++;
@ -1746,8 +1747,8 @@ namespace MinecraftClient
$"[raw pos=({location.X:F2},{location.Y:F2},{location.Z:F2})]");
using var cts = new CancellationTokenSource();
var result = finder.Calculate(ctx, sx, sy, sz,
new Pathing.Goals.GoalBlock(gx, gy, gz), cts.Token, timeoutMs);
var pathGoal = new Pathing.Goals.GoalBlock(gx, gy, gz);
var result = finder.Calculate(ctx, sx, sy, sz, pathGoal, cts.Token, timeoutMs);
Log.Info($"[Goto] A* result: {result.Status}, nodes={result.NodesExplored}, " +
$"time={result.ElapsedMs}ms, path length={result.Path.Count}");
@ -1758,27 +1759,23 @@ namespace MinecraftClient
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)
{
Log.Debug($"[Goto] wp[{logCount}] = ({wp.X:F1},{wp.Y:F1},{wp.Z:F1})");
logCount++;
Log.Debug($"[Goto] seg[{i - 1}] = {node.MoveUsed}: ({node.X},{node.Y},{node.Z})");
}
pathTarget = null;
path = queue;
path = null;
pathSegmentManager = new Pathing.Execution.PathSegmentManager(
debugLog: msg => Log.Debug(msg),
infoLog: msg => Log.Info(msg));
pathSegmentManager.StartNavigation(pathGoal, result);
string statusStr = result.Status == Pathing.Core.PathStatus.Partial ? " (partial)" : "";
return (true, string.Format(Translations.cmd_goto_success,
queue.Count, result.NodesExplored, result.ElapsedMs, statusStr));
result.Path.Count - 1, result.NodesExplored, result.ElapsedMs, statusStr));
}
}
@ -3261,26 +3258,30 @@ namespace MinecraftClient
}
/// <summary>
/// Drive the physics engine input based on the current A* path.
/// Converts discrete waypoint pathfinding into continuous movement input.
/// Drive the physics engine input based on the current path.
/// Uses template-based PathSegmentManager when available, falls back to legacy waypoints.
/// </summary>
private void UpdatePathfindingInput()
{
physicsInput.Reset();
// Advance waypoints when reached
// Template-based execution (new system)
if (pathSegmentManager is not null && pathSegmentManager.IsNavigating)
{
pathSegmentManager.Tick(location, playerPhysics, physicsInput, world);
playerYaw = playerPhysics.Yaw;
return;
}
// Legacy waypoint-based execution
if (pathTarget is not null && ReachedWaypoint(pathTarget.Value))
AdvanceWaypoint();
// First target from a fresh path
if (pathTarget is null && path is not null && path.Count > 0)
AdvanceWaypoint();
if (pathTarget is not null)
{
// Look-ahead: if this is a vertical-only waypoint and the next requires
// horizontal movement, merge them once we're close enough vertically.
// This handles the ladder-to-platform transition.
if (path is not null && path.Count > 0)
{
var target = pathTarget.Value;
@ -3297,8 +3298,6 @@ namespace MinecraftClient
double ndz = next.Z - target.Z;
bool nextIsHorizontal = ndx * ndx + ndz * ndz > 0.3;
// Skip to next waypoint early if we're within 1 block of the target Y
// and the next move requires horizontal movement
if (nextIsHorizontal && Math.Abs(dy) < 1.0)
{
AdvanceWaypoint();
@ -3421,7 +3420,14 @@ namespace MinecraftClient
/// <returns>true if a movement is currently handled</returns>
public bool ClientIsMoving()
{
return terrainAndMovementsEnabled && locationReceived && path is not null && path.Count > 0;
if (terrainAndMovementsEnabled && locationReceived)
{
if (pathSegmentManager is not null && pathSegmentManager.IsNavigating)
return true;
if (path is not null && path.Count > 0)
return true;
}
return false;
}
/// <summary>
@ -3441,6 +3447,9 @@ namespace MinecraftClient
{
bool success = ClientIsMoving();
path = null;
pathTarget = null;
pathSegmentManager?.Cancel();
pathSegmentManager = null;
return success;
}

View file

@ -49,6 +49,19 @@ namespace MinecraftClient.Pathing.Core
moves.Add(new MoveFall());
foreach (int dx in offsets)
{
for (int dist = 2; dist <= 3; dist++)
moves.Add(new MoveParkour(dx, 0, dist));
moves.Add(new MoveParkour(dx, 0, 2, yDelta: 1));
}
foreach (int dz in offsets)
{
for (int dist = 2; dist <= 3; dist++)
moves.Add(new MoveParkour(0, dz, dist));
moves.Add(new MoveParkour(0, dz, 2, yDelta: 1));
}
return [.. moves];
}

View file

@ -0,0 +1,27 @@
using System;
using MinecraftClient.Pathing.Core;
using MinecraftClient.Pathing.Execution.Templates;
namespace MinecraftClient.Pathing.Execution
{
/// <summary>
/// Maps a PathSegment (MoveType + start/end) to the appropriate IActionTemplate.
/// </summary>
public static class ActionTemplateFactory
{
public static IActionTemplate Create(PathSegment segment)
{
return segment.MoveType switch
{
MoveType.Traverse => new WalkTemplate(segment.Start, segment.End),
MoveType.Diagonal => new WalkTemplate(segment.Start, segment.End),
MoveType.Ascend => new AscendTemplate(segment.Start, segment.End),
MoveType.Descend => new DescendTemplate(segment.Start, segment.End),
MoveType.Fall => new FallTemplate(segment.Start, segment.End),
MoveType.Climb => new ClimbTemplate(segment.Start, segment.End),
MoveType.Parkour => new SprintJumpTemplate(segment.Start, segment.End),
_ => throw new ArgumentException($"Unknown MoveType: {segment.MoveType}")
};
}
}
}

View file

@ -0,0 +1,25 @@
using MinecraftClient.Mapping;
using MinecraftClient.Physics;
namespace MinecraftClient.Pathing.Execution
{
public enum TemplateState
{
InProgress,
Complete,
Failed
}
/// <summary>
/// Per-tick movement controller for one path segment.
/// Reads player state from physics, writes desired input to MovementInput,
/// and reports completion or failure.
/// </summary>
public interface IActionTemplate
{
Location ExpectedStart { get; }
Location ExpectedEnd { get; }
TemplateState Tick(Location currentPos, PlayerPhysics physics, MovementInput input);
}
}

View file

@ -0,0 +1,87 @@
using System;
using System.Collections.Generic;
using MinecraftClient.Mapping;
using MinecraftClient.Physics;
namespace MinecraftClient.Pathing.Execution
{
public enum PathExecutorState
{
InProgress,
Failed,
Complete
}
/// <summary>
/// Drives a sequence of PathSegments by instantiating the correct IActionTemplate
/// for each segment and ticking it every game tick.
/// </summary>
public sealed class PathExecutor
{
private readonly List<PathSegment> _segments;
private int _currentIndex;
private IActionTemplate? _currentTemplate;
private readonly Action<string>? _debugLog;
public bool IsComplete => _currentIndex >= _segments.Count && _currentTemplate is null;
public int CurrentIndex => _currentIndex;
public int TotalSegments => _segments.Count;
public PathSegment? CurrentSegment =>
_currentIndex < _segments.Count ? _segments[_currentIndex] : null;
public PathExecutor(List<PathSegment> segments, Action<string>? debugLog = null)
{
_segments = segments;
_currentIndex = 0;
_debugLog = debugLog;
AdvanceToNextSegment();
}
public PathExecutorState Tick(Location pos, PlayerPhysics physics, MovementInput input)
{
if (_currentTemplate is null)
return PathExecutorState.Complete;
var state = _currentTemplate.Tick(pos, physics, input);
switch (state)
{
case TemplateState.Complete:
_debugLog?.Invoke($"[PathExec] Segment {_currentIndex} complete " +
$"({_segments[_currentIndex].MoveType}) at ({pos.X:F2},{pos.Y:F2},{pos.Z:F2})");
_currentIndex++;
if (_currentIndex >= _segments.Count)
{
_currentTemplate = null;
_debugLog?.Invoke("[PathExec] All segments complete!");
return PathExecutorState.Complete;
}
AdvanceToNextSegment();
return PathExecutorState.InProgress;
case TemplateState.Failed:
_debugLog?.Invoke($"[PathExec] Segment {_currentIndex} FAILED " +
$"({_segments[_currentIndex].MoveType}) at ({pos.X:F2},{pos.Y:F2},{pos.Z:F2}), " +
$"target was ({_currentTemplate.ExpectedEnd.X:F2},{_currentTemplate.ExpectedEnd.Y:F2},{_currentTemplate.ExpectedEnd.Z:F2})");
return PathExecutorState.Failed;
default:
return PathExecutorState.InProgress;
}
}
private void AdvanceToNextSegment()
{
if (_currentIndex < _segments.Count)
{
var seg = _segments[_currentIndex];
_currentTemplate = ActionTemplateFactory.Create(seg);
_debugLog?.Invoke($"[PathExec] Starting segment {_currentIndex}/{_segments.Count}: {seg}");
}
else
{
_currentTemplate = null;
}
}
}
}

View file

@ -0,0 +1,33 @@
using System.Collections.Generic;
using MinecraftClient.Mapping;
using MinecraftClient.Pathing.Core;
namespace MinecraftClient.Pathing.Execution
{
public sealed class PathSegment
{
public required Location Start { get; init; }
public required Location End { get; init; }
public required MoveType MoveType { get; init; }
public static List<PathSegment> FromPath(IReadOnlyList<PathNode> nodes)
{
var segments = new List<PathSegment>(nodes.Count - 1);
for (int i = 1; i < nodes.Count; i++)
{
var prev = nodes[i - 1];
var curr = nodes[i];
segments.Add(new PathSegment
{
Start = new Location(prev.X + 0.5, prev.Y, prev.Z + 0.5),
End = new Location(curr.X + 0.5, curr.Y, curr.Z + 0.5),
MoveType = curr.MoveUsed
});
}
return segments;
}
public override string ToString() =>
$"{MoveType}: ({Start.X:F1},{Start.Y:F1},{Start.Z:F1})->({End.X:F1},{End.Y:F1},{End.Z:F1})";
}
}

View file

@ -0,0 +1,120 @@
using System;
using System.Threading;
using MinecraftClient.Mapping;
using MinecraftClient.Pathing.Core;
using MinecraftClient.Pathing.Goals;
using MinecraftClient.Physics;
namespace MinecraftClient.Pathing.Execution
{
/// <summary>
/// Top-level navigation controller. Holds a PathExecutor, monitors its progress,
/// and triggers replanning on failure or deviation.
/// </summary>
public sealed class PathSegmentManager
{
private PathExecutor? _executor;
private IGoal? _goal;
private int _replanCount;
private const int MaxReplans = 5;
private readonly Action<string>? _debugLog;
private readonly Action<string>? _infoLog;
public bool IsNavigating => _executor is not null && !_executor.IsComplete;
public int ReplanCount => _replanCount;
public PathSegmentManager(Action<string>? debugLog = null, Action<string>? infoLog = null)
{
_debugLog = debugLog;
_infoLog = infoLog;
}
public void StartNavigation(IGoal goal, PathResult result)
{
_goal = goal;
_replanCount = 0;
var segments = PathSegment.FromPath(result.Path);
_executor = new PathExecutor(segments, _debugLog);
_infoLog?.Invoke($"[PathMgr] Navigation started: {segments.Count} segments");
}
public void Tick(Location pos, PlayerPhysics physics, MovementInput input, World world)
{
if (_executor is null)
return;
var state = _executor.Tick(pos, physics, input);
switch (state)
{
case PathExecutorState.Complete:
_infoLog?.Invoke("[PathMgr] Navigation complete!");
_executor = null;
_goal = null;
break;
case PathExecutorState.Failed:
_infoLog?.Invoke("[PathMgr] Segment failed, replanning...");
Replan(pos, world);
break;
}
}
public void Cancel()
{
if (_executor is not null)
{
_infoLog?.Invoke("[PathMgr] Navigation cancelled.");
_executor = null;
_goal = null;
}
}
private void Replan(Location pos, World world)
{
_replanCount++;
if (_replanCount > MaxReplans)
{
_infoLog?.Invoke($"[PathMgr] Giving up after {MaxReplans} replans.");
_executor = null;
_goal = null;
return;
}
if (_goal is null)
{
_executor = null;
return;
}
_debugLog?.Invoke($"[PathMgr] Replan #{_replanCount} from ({pos.X:F2},{pos.Y:F2},{pos.Z:F2})");
var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true);
var finder = new AStarPathFinder();
finder.DebugLog = _debugLog;
int sx = (int)Math.Floor(pos.X);
int sy = (int)Math.Floor(pos.Y);
int sz = (int)Math.Floor(pos.Z);
if (!ctx.CanWalkThrough(sx, sy, sz) && ctx.CanWalkThrough(sx, sy + 1, sz))
sy++;
using var cts = new CancellationTokenSource();
var result = finder.Calculate(ctx, sx, sy, sz, _goal, cts.Token, 3000);
if (result.Status == PathStatus.Failed || result.Path.Count < 2)
{
_infoLog?.Invoke("[PathMgr] Replan failed -- no path found.");
_executor = null;
_goal = null;
return;
}
var segments = PathSegment.FromPath(result.Path);
_executor = new PathExecutor(segments, _debugLog);
_infoLog?.Invoke($"[PathMgr] Replanned: {segments.Count} segments (replan #{_replanCount})");
}
}
}

View file

@ -0,0 +1,59 @@
using System;
using MinecraftClient.Mapping;
using MinecraftClient.Physics;
namespace MinecraftClient.Pathing.Execution.Templates
{
/// <summary>
/// Jump up 1 block while moving 1 block in a cardinal direction.
/// Faces destination, sprints forward, and jumps when on ground.
/// </summary>
public sealed class AscendTemplate : IActionTemplate
{
public Location ExpectedStart { get; }
public Location ExpectedEnd { get; }
private int _tickCount;
private Location _lastPos;
private int _stuckTicks;
public AscendTemplate(Location start, Location end)
{
ExpectedStart = start;
ExpectedEnd = end;
_lastPos = start;
}
public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input)
{
_tickCount++;
double dx = ExpectedEnd.X - pos.X;
double dz = ExpectedEnd.Z - pos.Z;
double dy = ExpectedEnd.Y - pos.Y;
double horizDistSq = dx * dx + dz * dz;
// Complete when close to destination. Sprint bouncing can leave the player
// slightly above ground, so we don't require OnGround here.
if (horizDistSq < 0.25 && Math.Abs(dy) < 0.8)
return TemplateState.Complete;
double movedSq = TemplateHelper.HorizontalDistanceSq(pos, _lastPos);
double movedY = Math.Abs(pos.Y - _lastPos.Y);
_stuckTicks = (movedSq < 0.0005 && movedY < 0.001) ? _stuckTicks + 1 : 0;
_lastPos = pos;
if (_stuckTicks > 40 || _tickCount > 80)
return TemplateState.Failed;
physics.Yaw = TemplateHelper.CalculateYaw(dx, dz);
input.Forward = true;
input.Sprint = true;
if (physics.OnGround && dy > 0.1)
input.Jump = true;
return TemplateState.InProgress;
}
}
}

View file

@ -0,0 +1,63 @@
using System;
using MinecraftClient.Mapping;
using MinecraftClient.Physics;
namespace MinecraftClient.Pathing.Execution.Templates
{
/// <summary>
/// Climb up or down a ladder/vine by 1 block.
/// Pushes against the wall (Forward + face center) and jumps for upward movement.
/// </summary>
public sealed class ClimbTemplate : IActionTemplate
{
public Location ExpectedStart { get; }
public Location ExpectedEnd { get; }
private int _tickCount;
public ClimbTemplate(Location start, Location end)
{
ExpectedStart = start;
ExpectedEnd = end;
}
public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input)
{
_tickCount++;
double dy = ExpectedEnd.Y - pos.Y;
double dx = ExpectedEnd.X - pos.X;
double dz = ExpectedEnd.Z - pos.Z;
double horizDistSq = dx * dx + dz * dz;
if (Math.Abs(dy) < 0.3 && horizDistSq < 0.5)
return TemplateState.Complete;
if (_tickCount > 100)
return TemplateState.Failed;
if (physics.OnClimbable)
{
if (dy > 0)
{
input.Jump = true;
input.Forward = true;
if (horizDistSq > 0.01)
physics.Yaw = TemplateHelper.CalculateYaw(dx, dz);
}
// Going down: don't press anything, gravity + climbable friction handles it
}
else
{
// Left the climbable area -- walk toward destination
if (horizDistSq > 0.01)
{
physics.Yaw = TemplateHelper.CalculateYaw(dx, dz);
input.Forward = true;
}
}
return TemplateState.InProgress;
}
}
}

View file

@ -0,0 +1,55 @@
using System;
using MinecraftClient.Mapping;
using MinecraftClient.Physics;
namespace MinecraftClient.Pathing.Execution.Templates
{
/// <summary>
/// Walk off a ledge and drop 1-N blocks to a landing spot.
/// Walks toward the destination; gravity handles the fall.
/// </summary>
public sealed class DescendTemplate : IActionTemplate
{
public Location ExpectedStart { get; }
public Location ExpectedEnd { get; }
private int _tickCount;
private bool _hasFallen;
public DescendTemplate(Location start, Location end)
{
ExpectedStart = start;
ExpectedEnd = end;
}
public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input)
{
_tickCount++;
double dx = ExpectedEnd.X - pos.X;
double dz = ExpectedEnd.Z - pos.Z;
double dy = ExpectedEnd.Y - pos.Y;
double horizDistSq = dx * dx + dz * dz;
if (!physics.OnGround)
_hasFallen = true;
if (_hasFallen && physics.OnGround && horizDistSq < 0.5 && Math.Abs(dy) < 0.8)
return TemplateState.Complete;
if (horizDistSq < 0.25 && Math.Abs(dy) < 0.5 && physics.OnGround)
return TemplateState.Complete;
if (_tickCount > 120)
return TemplateState.Failed;
if (horizDistSq > 0.01)
{
physics.Yaw = TemplateHelper.CalculateYaw(dx, dz);
input.Forward = true;
}
return TemplateState.InProgress;
}
}
}

View file

@ -0,0 +1,42 @@
using System;
using MinecraftClient.Mapping;
using MinecraftClient.Physics;
namespace MinecraftClient.Pathing.Execution.Templates
{
/// <summary>
/// Vertical free fall at the same X,Z. Waits for the player to land at the target Y.
/// </summary>
public sealed class FallTemplate : IActionTemplate
{
public Location ExpectedStart { get; }
public Location ExpectedEnd { get; }
private int _tickCount;
private bool _hasFallen;
public FallTemplate(Location start, Location end)
{
ExpectedStart = start;
ExpectedEnd = end;
}
public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input)
{
_tickCount++;
double dy = pos.Y - ExpectedEnd.Y;
if (!physics.OnGround)
_hasFallen = true;
if (_hasFallen && physics.OnGround && Math.Abs(dy) < 1.0)
return TemplateState.Complete;
if (_tickCount > 200)
return TemplateState.Failed;
return TemplateState.InProgress;
}
}
}

View file

@ -0,0 +1,79 @@
using System;
using MinecraftClient.Mapping;
using MinecraftClient.Physics;
namespace MinecraftClient.Pathing.Execution.Templates
{
/// <summary>
/// Sprint-jump across a gap. Uses a phase-based state machine:
/// Approach -> jump on first available ground tick -> Airborne -> Landing check.
/// </summary>
public sealed class SprintJumpTemplate : IActionTemplate
{
private enum Phase { Approach, Airborne, Landing }
public Location ExpectedStart { get; }
public Location ExpectedEnd { get; }
private int _tickCount;
private Phase _phase = Phase.Approach;
private readonly int _distance;
public SprintJumpTemplate(Location start, Location end)
{
ExpectedStart = start;
ExpectedEnd = end;
double dx = Math.Abs(end.X - start.X);
double dz = Math.Abs(end.Z - start.Z);
_distance = (int)Math.Round(Math.Max(dx, dz));
}
public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input)
{
_tickCount++;
double dx = ExpectedEnd.X - pos.X;
double dz = ExpectedEnd.Z - pos.Z;
double dy = ExpectedEnd.Y - pos.Y;
double horizDistSq = dx * dx + dz * dz;
physics.Yaw = TemplateHelper.CalculateYaw(dx, dz);
input.Forward = true;
input.Sprint = true;
switch (_phase)
{
case Phase.Approach:
if (physics.OnGround)
{
input.Jump = true;
_phase = Phase.Airborne;
}
if (_tickCount > 20)
return TemplateState.Failed;
break;
case Phase.Airborne:
if (!physics.OnGround)
break;
// Landed
_phase = Phase.Landing;
goto case Phase.Landing;
case Phase.Landing:
if (horizDistSq < 2.0 && Math.Abs(dy) < 1.0)
return TemplateState.Complete;
return TemplateState.Failed;
}
if (pos.Y < ExpectedEnd.Y - 4.0)
return TemplateState.Failed;
if (_tickCount > 60)
return TemplateState.Failed;
return TemplateState.InProgress;
}
}
}

View file

@ -0,0 +1,31 @@
using System;
using MinecraftClient.Mapping;
namespace MinecraftClient.Pathing.Execution.Templates
{
internal static class TemplateHelper
{
internal static float CalculateYaw(double dx, double dz)
{
float yaw = (float)(-Math.Atan2(dx, dz) / Math.PI * 180.0);
if (yaw < 0) yaw += 360;
return yaw;
}
internal static double HorizontalDistanceSq(Location a, Location b)
{
double dx = a.X - b.X;
double dz = a.Z - b.Z;
return dx * dx + dz * dz;
}
internal static bool IsNear(Location pos, Location target,
double horizThresholdSq = 0.25, double vertThreshold = 0.8)
{
double dx = target.X - pos.X;
double dz = target.Z - pos.Z;
double dy = target.Y - pos.Y;
return dx * dx + dz * dz < horizThresholdSq && Math.Abs(dy) < vertThreshold;
}
}
}

View file

@ -0,0 +1,51 @@
using System;
using MinecraftClient.Mapping;
using MinecraftClient.Physics;
namespace MinecraftClient.Pathing.Execution.Templates
{
/// <summary>
/// Walk/sprint toward a destination on the same Y level.
/// Used for Traverse and Diagonal moves.
/// </summary>
public sealed class WalkTemplate : IActionTemplate
{
public Location ExpectedStart { get; }
public Location ExpectedEnd { get; }
private int _tickCount;
private Location _lastPos;
private int _stuckTicks;
public WalkTemplate(Location start, Location end)
{
ExpectedStart = start;
ExpectedEnd = end;
_lastPos = start;
}
public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input)
{
_tickCount++;
if (TemplateHelper.IsNear(pos, ExpectedEnd, horizThresholdSq: 0.20))
return TemplateState.Complete;
double movedSq = TemplateHelper.HorizontalDistanceSq(pos, _lastPos);
_stuckTicks = movedSq < 0.0005 ? _stuckTicks + 1 : 0;
_lastPos = pos;
if (_stuckTicks > 40 || _tickCount > 100)
return TemplateState.Failed;
double dx = ExpectedEnd.X - pos.X;
double dz = ExpectedEnd.Z - pos.Z;
physics.Yaw = TemplateHelper.CalculateYaw(dx, dz);
input.Forward = true;
input.Sprint = true;
return TemplateState.InProgress;
}
}
}

View file

@ -0,0 +1,113 @@
using MinecraftClient.Pathing.Core;
namespace MinecraftClient.Pathing.Moves.Impl
{
/// <summary>
/// Sprint jump across a gap of 1-3 blocks (total distance 2-4 blocks forward).
/// Optionally ascends 1 block during the jump (distance 2 only).
/// Requires AllowParkour in context; the first block forward must lack ground.
/// </summary>
public sealed class MoveParkour : IMove
{
public MoveType Type => MoveType.Parkour;
public int XOffset { get; }
public int ZOffset { get; }
public bool DynamicY => false;
private readonly int _distance;
private readonly int _yDelta;
private readonly int _xDir;
private readonly int _zDir;
public MoveParkour(int xDir, int zDir, int distance, int yDelta = 0)
{
_xDir = xDir;
_zDir = zDir;
_distance = distance;
_yDelta = yDelta;
XOffset = xDir * distance;
ZOffset = zDir * distance;
}
public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result)
{
if (!ctx.AllowParkour)
{
result.SetImpossible();
return;
}
if (_yDelta > 0 && !ctx.AllowParkourAscend)
{
result.SetImpossible();
return;
}
if (!ctx.CanSprint)
{
result.SetImpossible();
return;
}
int destX = x + _xDir * _distance;
int destZ = z + _zDir * _distance;
int destY = y + _yDelta;
if (!ctx.CanWalkThrough(x, y + 2, z))
{
result.SetImpossible();
return;
}
if (!ctx.CanWalkOn(destX, destY - 1, destZ))
{
result.SetImpossible();
return;
}
if (!ctx.CanWalkThrough(destX, destY, destZ) ||
!ctx.CanWalkThrough(destX, destY + 1, destZ))
{
result.SetImpossible();
return;
}
for (int i = 1; i < _distance; i++)
{
int gx = x + _xDir * i;
int gz = z + _zDir * i;
if (!ctx.CanWalkThrough(gx, y, gz) ||
!ctx.CanWalkThrough(gx, y + 1, gz) ||
!ctx.CanWalkThrough(gx, y + 2, gz))
{
result.SetImpossible();
return;
}
if (_yDelta > 0 && !ctx.CanWalkThrough(gx, y + 3, gz))
{
result.SetImpossible();
return;
}
}
int firstGapX = x + _xDir;
int firstGapZ = z + _zDir;
if (ctx.CanWalkOn(firstGapX, y - 1, firstGapZ))
{
result.SetImpossible();
return;
}
double cost = _distance * ctx.SprintCost + ctx.JumpPenalty;
if (_yDelta > 0)
cost += ctx.JumpPenalty;
result.Set(destX, destY, destZ, cost);
}
public override string ToString() =>
$"MoveParkour(dir=({_xDir},{_zDir}), dist={_distance}, dy={_yDelta})";
}
}