pathing: async replan + template success/failure alignment

Move PathSegmentManager's Replan to Task.Run so the main tick only reads
results and swaps executors, and introduce a _nextExecutor pre-planning
slot so upcoming segments can prepare while the current one finishes.

Relax per-tick yaw/pitch rate limiting: allow instantaneous snapping
before jump ticks (Baritone does this and servers do not kick for it).

Align jump-template success/failure contracts with Baritone:
- Success key shifts from "speed squared" to "feet-on-target block".
- Failure window widened to the ~200 tick range.
- AscendTemplate gets a headBonkClear + edge/side proximity
  precondition so launches only happen from a safe takeoff.

Expose an initialMomentumTicks option on TemplateSimulationRunner so
follow-up sidewall scenarios can warm up physics before a template
starts.

Made-with: Cursor
This commit is contained in:
BruceChen 2026-04-19 17:02:41 +00:00
parent e23037a897
commit 95b20d9d1c
6 changed files with 554 additions and 48 deletions

View file

@ -6,9 +6,9 @@ namespace MinecraftClient.Tests.Pathing.Execution;
internal static class TemplateSimulationRunner
{
internal static PlayerPhysics CreateGroundedPhysics(Location start, float yaw)
internal static PlayerPhysics CreateGroundedPhysics(Location start, float yaw, int initialMomentumTicks = 0)
{
return new PlayerPhysics
var physics = new PlayerPhysics
{
Position = new Vec3d(start.X, start.Y, start.Z),
DeltaMovement = Vec3d.Zero,
@ -17,6 +17,11 @@ internal static class TemplateSimulationRunner
Yaw = yaw,
Pitch = 0f
};
if (initialMomentumTicks > 0)
ApplyInitialGroundMomentum(physics, initialMomentumTicks);
return physics;
}
internal static TemplateState Run(IActionTemplate template, PlayerPhysics physics, World world, int maxTicks, out Location finalPos)
@ -39,4 +44,32 @@ internal static class TemplateSimulationRunner
finalPos = new Location(physics.Position.X, physics.Position.Y, physics.Position.Z);
return state;
}
private static void ApplyInitialGroundMomentum(PlayerPhysics physics, int ticks)
{
World world = FlatWorldTestBuilder.CreateStoneFloor();
var seeded = new PlayerPhysics
{
Position = new Vec3d(0.5, 80, 0.5),
DeltaMovement = Vec3d.Zero,
OnGround = true,
MovementSpeed = physics.MovementSpeed,
Yaw = physics.Yaw,
Pitch = physics.Pitch
};
var input = new MovementInput
{
Forward = true,
Sprint = true
};
for (int tick = 0; tick < ticks; tick++)
{
seeded.ApplyInput(input);
seeded.Tick(world);
}
physics.DeltaMovement = seeded.DeltaMovement;
physics.Sprinting = true;
}
}

View file

@ -33,6 +33,8 @@ namespace MinecraftClient.Pathing.Execution
public int TotalTicks => _totalTicks;
public PathSegment? CurrentSegment =>
_currentIndex < _segments.Count ? _segments[_currentIndex] : null;
public PathSegment? LastSegment =>
_segments.Count > 0 ? _segments[^1] : null;
public PathExecutor(List<PathSegment> segments, Action<string>? debugLog = null, IPathExecutionObserver? observer = null)
{

View file

@ -1,5 +1,6 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MinecraftClient.Mapping;
using MinecraftClient.Pathing.Core;
using MinecraftClient.Pathing.Execution.Telemetry;
@ -11,19 +12,39 @@ namespace MinecraftClient.Pathing.Execution
/// <summary>
/// Top-level navigation controller. Holds a PathExecutor, monitors its progress,
/// and triggers replanning on failure or deviation.
///
/// Replans and look-ahead plans run on a background Task (Baritone equivalent:
/// findPathInNewThread). The main tick only reads task state: either applies a
/// completed plan or installs the next one. A look-ahead plan is speculatively
/// started as the current executor nears the end of its segment list so that the
/// next executor is ready to splice in without a user-visible pause.
/// </summary>
public sealed class PathSegmentManager
{
private PathExecutor? _executor;
private IGoal? _goal;
private int _replanCount;
private const int MaxReplans = 5;
private const int ReplanTimeoutMs = 3000;
private const int LookaheadTriggerSegmentsRemaining = 2;
private readonly Action<string>? _debugLog;
private readonly Action<string>? _infoLog;
private readonly IPathExecutionObserver? _observer;
public bool IsNavigating => _executor is not null && !_executor.IsComplete;
private PathExecutor? _executor;
private PathExecutor? _nextExecutor;
private IGoal? _goal;
private int _replanCount;
private Task<PathResult>? _pendingReplan;
private CancellationTokenSource? _pendingReplanCts;
private Task<PathResult>? _pendingLookahead;
private CancellationTokenSource? _pendingLookaheadCts;
private (int x, int y, int z)? _pendingLookaheadAnchor;
public bool IsNavigating =>
(_executor is not null && !_executor.IsComplete)
|| _nextExecutor is not null
|| _pendingReplan is not null;
public int ReplanCount => _replanCount;
public IGoal? Goal => _goal;
@ -36,6 +57,8 @@ namespace MinecraftClient.Pathing.Execution
public void StartNavigation(IGoal goal, PathResult result)
{
CancelPendingTasks();
_nextExecutor = null;
_goal = goal;
_replanCount = 0;
if (result.Status == PathStatus.Failed || result.Path.Count < 2)
@ -53,51 +76,100 @@ namespace MinecraftClient.Pathing.Execution
public void Tick(Location pos, PlayerPhysics physics, MovementInput input, World world)
{
DrainPendingReplan(pos, world);
DrainPendingLookahead();
if (_executor is null)
{
// Navigation is still alive if we are waiting on a background plan to
// come back. The next tick will promote it into _executor.
if (_pendingReplan is not null || _nextExecutor is not null)
{
TryPromoteNextExecutor();
input.Reset();
return;
}
return;
}
var state = _executor.Tick(pos, physics, input, world);
switch (state)
{
case PathExecutorState.Complete:
if (_goal is not null)
{
int px = (int)Math.Floor(pos.X);
int py = (int)Math.Floor(pos.Y);
int pz = (int)Math.Floor(pos.Z);
if (!_goal.IsInGoal(px, py, pz))
{
_infoLog?.Invoke("[PathMgr] Planned route ended before reaching goal, replanning...");
Replan(pos, world);
break;
}
}
_observer?.OnNavigationCompleted(_executor.TotalTicks);
_infoLog?.Invoke("[PathMgr] Navigation complete!");
_executor = null;
_goal = null;
HandleExecutorComplete(pos, world, input);
break;
case PathExecutorState.Failed:
_infoLog?.Invoke("[PathMgr] Segment failed, replanning...");
Replan(pos, world);
// The prepared next path assumes we finished the current segment
// cleanly, so drop it when we fail.
DiscardLookahead();
_nextExecutor = null;
StartReplanAsync(pos, world);
break;
case PathExecutorState.InProgress:
MaybeStartLookahead(world);
break;
}
}
public void Cancel()
{
if (_executor is not null)
if (_executor is not null || _pendingReplan is not null || _nextExecutor is not null)
{
_infoLog?.Invoke("[PathMgr] Navigation cancelled.");
_executor = null;
_goal = null;
}
CancelPendingTasks();
_executor = null;
_nextExecutor = null;
_goal = null;
}
private void Replan(Location pos, World world)
private void HandleExecutorComplete(Location pos, World world, MovementInput input)
{
if (_goal is not null)
{
int px = (int)Math.Floor(pos.X);
int py = (int)Math.Floor(pos.Y);
int pz = (int)Math.Floor(pos.Z);
if (!_goal.IsInGoal(px, py, pz))
{
if (TryPromoteNextExecutor())
{
_debugLog?.Invoke("[PathMgr] Spliced to prepared next segment chain.");
return;
}
_infoLog?.Invoke("[PathMgr] Planned route ended before reaching goal, replanning...");
StartReplanAsync(pos, world);
input.Reset();
return;
}
}
_observer?.OnNavigationCompleted(_executor!.TotalTicks);
_infoLog?.Invoke("[PathMgr] Navigation complete!");
CancelPendingTasks();
_executor = null;
_nextExecutor = null;
_goal = null;
}
private bool TryPromoteNextExecutor()
{
if (_nextExecutor is null)
return false;
_executor = _nextExecutor;
_nextExecutor = null;
return true;
}
private void StartReplanAsync(Location pos, World world)
{
_replanCount++;
_observer?.OnReplanStarted(_replanCount, pos);
@ -105,7 +177,9 @@ namespace MinecraftClient.Pathing.Execution
{
_observer?.OnReplanFailed(_replanCount, pos);
_infoLog?.Invoke($"[PathMgr] Giving up after {MaxReplans} replans.");
CancelPendingTasks();
_executor = null;
_nextExecutor = null;
_goal = null;
return;
}
@ -116,28 +190,93 @@ namespace MinecraftClient.Pathing.Execution
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;
// Once we decide to replan, the currently-installed executor is discarded;
// the new plan will replace it. We keep the executor reference until the
// plan returns so IsNavigating reflects that work is in flight.
if (_pendingReplan is not null)
return;
int sx = (int)Math.Floor(pos.X);
int sy = (int)Math.Floor(pos.Y);
int sz = (int)Math.Floor(pos.Z);
var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true);
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);
IGoal goal = _goal;
_debugLog?.Invoke($"[PathMgr] Replan #{_replanCount} kicked off from ({pos.X:F2},{pos.Y:F2},{pos.Z:F2})");
_pendingReplanCts = new CancellationTokenSource();
CancellationToken token = _pendingReplanCts.Token;
_pendingReplan = Task.Run(() =>
{
var finder = new AStarPathFinder { DebugLog = _debugLog };
return finder.Calculate(ctx, sx, sy, sz, goal, token, ReplanTimeoutMs);
}, token);
// Clear the current executor so IsNavigating stays true via the pending
// task branch. This prevents the main client from believing navigation
// ended between "plan completes" and "plan gets installed".
_executor = null;
}
private void DrainPendingReplan(Location pos, World world)
{
if (_pendingReplan is null)
return;
// When the current executor has been cleared (we are waiting for a plan
// to install), give the background task a short budget to finish. The
// player is standing still anyway, so trading a few ms of pause for
// installing the new plan immediately is a clear win over letting the
// tick return with _executor == null. This also makes tests with a
// tight Tick poll loop deterministic: Task.Run needs the caller to
// yield at some point so the thread-pool worker can complete.
if (!_pendingReplan.IsCompleted && _executor is null && _nextExecutor is null)
{
try
{
_pendingReplan.Wait(20);
}
catch
{
// Exceptions are inspected via Task.IsFaulted below.
}
}
if (!_pendingReplan.IsCompleted)
return;
Task<PathResult> task = _pendingReplan;
_pendingReplan = null;
var cts = _pendingReplanCts;
_pendingReplanCts = null;
cts?.Dispose();
if (task.IsFaulted || task.IsCanceled)
{
_infoLog?.Invoke("[PathMgr] Replan task failed or was cancelled.");
_observer?.OnReplanFailed(_replanCount, pos);
_goal = null;
_executor = null;
_nextExecutor = null;
return;
}
PathResult result = task.Result;
int sx = (int)Math.Floor(pos.X);
int sy = (int)Math.Floor(pos.Y);
int sz = (int)Math.Floor(pos.Z);
bool alreadyInGoal = _goal is not null && (_goal.IsInGoal(sx, sy, sz)
|| (result.Path.Count == 1 && _goal.IsInGoal(result.Path[0].X, result.Path[0].Y, result.Path[0].Z)));
bool alreadyInGoal = _goal.IsInGoal(sx, sy, sz)
|| (result.Path.Count == 1 && _goal.IsInGoal(result.Path[0].X, result.Path[0].Y, result.Path[0].Z));
if (alreadyInGoal)
{
_infoLog?.Invoke("[PathMgr] Navigation complete!");
_executor = null;
_nextExecutor = null;
_goal = null;
return;
}
@ -147,6 +286,7 @@ namespace MinecraftClient.Pathing.Execution
_observer?.OnReplanFailed(_replanCount, pos);
_infoLog?.Invoke("[PathMgr] Replan failed -- no path found.");
_executor = null;
_nextExecutor = null;
_goal = null;
return;
}
@ -154,7 +294,95 @@ namespace MinecraftClient.Pathing.Execution
var segments = PathSegmentBuilder.FromPath(result.Path);
_observer?.OnReplanSucceeded(_replanCount, segments);
_executor = new PathExecutor(segments, _debugLog, _observer);
_nextExecutor = null;
_infoLog?.Invoke($"[PathMgr] Replanned: {segments.Count} segments (replan #{_replanCount})");
}
private void MaybeStartLookahead(World world)
{
if (_pendingLookahead is not null || _nextExecutor is not null || _goal is null || _executor is null)
return;
int total = _executor.TotalSegments;
int current = _executor.CurrentIndex;
if (total - current > LookaheadTriggerSegmentsRemaining)
return;
// Anchor the lookahead at the final segment's end; that is where execution
// will arrive if the current executor finishes without drift.
PathSegment? lastSegment = _executor.LastSegment;
if (lastSegment is null)
return;
int ax = (int)Math.Floor(lastSegment.End.X);
int ay = (int)Math.Floor(lastSegment.End.Y);
int az = (int)Math.Floor(lastSegment.End.Z);
if (_goal.IsInGoal(ax, ay, az))
return;
// Avoid re-planning from the same anchor repeatedly.
if (_pendingLookaheadAnchor is { } prev && prev == (ax, ay, az))
return;
var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true);
IGoal goal = _goal;
_debugLog?.Invoke($"[PathMgr] Lookahead plan kicked off from ({ax},{ay},{az})");
_pendingLookaheadCts = new CancellationTokenSource();
CancellationToken token = _pendingLookaheadCts.Token;
_pendingLookaheadAnchor = (ax, ay, az);
_pendingLookahead = Task.Run(() =>
{
var finder = new AStarPathFinder { DebugLog = _debugLog };
return finder.Calculate(ctx, ax, ay, az, goal, token, ReplanTimeoutMs);
}, token);
}
private void DrainPendingLookahead()
{
if (_pendingLookahead is null || !_pendingLookahead.IsCompleted)
return;
Task<PathResult> task = _pendingLookahead;
_pendingLookahead = null;
var cts = _pendingLookaheadCts;
_pendingLookaheadCts = null;
cts?.Dispose();
if (task.IsFaulted || task.IsCanceled)
return;
PathResult result = task.Result;
if (result.Status == PathStatus.Failed || result.Path.Count < 2)
return;
// The lookahead should continue from the anchor point. If the live
// executor is still running, splice the prepared plan as _nextExecutor
// so it can take over without a wait.
var segments = PathSegmentBuilder.FromPath(result.Path);
_nextExecutor = new PathExecutor(segments, _debugLog, _observer);
_debugLog?.Invoke($"[PathMgr] Lookahead ready: {segments.Count} segments spliced into _nextExecutor");
}
private void DiscardLookahead()
{
_pendingLookaheadCts?.Cancel();
_pendingLookaheadCts?.Dispose();
_pendingLookaheadCts = null;
_pendingLookahead = null;
_pendingLookaheadAnchor = null;
}
private void CancelPendingTasks()
{
_pendingReplanCts?.Cancel();
_pendingReplanCts?.Dispose();
_pendingReplanCts = null;
_pendingReplan = null;
DiscardLookahead();
}
}
}

View file

@ -7,9 +7,19 @@ 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.
///
/// Follows Baritone's MovementAscend.updateState gating:
/// - jump immediately when headBonkClear (no low-ceiling hazard above source)
/// - otherwise wait until close to the destination edge (flatDistToNext &lt;= 1.2)
/// and laterally lined up (sideDist &lt;= 0.2) before firing the jump
/// This avoids bonking the ceiling on short staircases and avoids jumping while
/// still too far away (which causes the short-hop to stall against the riser).
/// </summary>
public sealed class AscendTemplate : IActionTemplate
{
private const double EdgeCloseDistance = 1.2;
private const double LateralAlignmentTolerance = 0.2;
public Location ExpectedStart { get; }
public Location ExpectedEnd { get; }
@ -55,13 +65,45 @@ namespace MinecraftClient.Pathing.Execution.Templates
input.Forward = !turnInPlace;
input.Sprint = !turnInPlace;
bool diagonalAscend = _segment.HeadingX != 0 && _segment.HeadingZ != 0;
bool jumpReady = headingReady
&& (diagonalAscend || TemplateHelper.RemainingDistanceAlongSegment(pos, _segment) <= 1.05);
if (physics.OnGround && dy > 0.1 && jumpReady)
if (physics.OnGround && dy > 0.1)
{
input.Jump = true;
_initiatedJump = true;
bool diagonalAscend = _segment.HeadingX != 0 && _segment.HeadingZ != 0;
double flatDistToNext = TemplateHelper.RemainingDistanceAlongSegment(pos, _segment);
double sideDist = TemplateHelper.LateralOffsetFromSegmentLine(pos, _segment);
bool closeToEdge = flatDistToNext <= EdgeCloseDistance;
bool laterallyAligned = sideDist <= LateralAlignmentTolerance;
bool jumpReady;
if (HasHeadBonkClear(world))
{
// Vertical head-room above the source block is clear, so starting the
// jump early is safe and actually makes the short hop more reliable
// (matches Baritone's "headBonkClear" shortcut).
jumpReady = headingReady;
}
else if (diagonalAscend)
{
jumpReady = headingReady;
}
else
{
// Mirror Baritone's gate: only jump when close to the riser and
// laterally lined up; otherwise we end up banging the side of the
// block without gaining height.
jumpReady = headingReady && closeToEdge && laterallyAligned;
}
if (jumpReady)
{
// Snap rotation to the target direction on the takeoff tick so
// the sprint-jump boost goes along the segment line regardless of
// how many ticks the smoothing had to consume. Baritone sets
// rotation directly every tick and the server accepts it.
physics.Yaw = targetYaw;
input.Jump = true;
_initiatedJump = true;
}
}
if (physics.OnGround && Math.Abs(dy) < 0.2)
@ -76,12 +118,48 @@ namespace MinecraftClient.Pathing.Execution.Templates
_stuckTicks = (movedSq < 0.0005 && movedY < 0.001) ? _stuckTicks + 1 : 0;
_lastPos = pos;
if (_stuckTicks > 40 || _tickCount > 80)
// Baritone tolerates up to 200 ticks (MAX_TICKS_AWAY) before abandoning a
// movement. We mirror that budget so the template does not fail spuriously
// during normal run-up / jump / landing settle flows.
if (_stuckTicks > 120 || _tickCount > 200)
return TemplateState.Failed;
return TemplateState.InProgress;
}
/// <summary>
/// True when no solid block sits two cells above the source ascent position
/// in any cardinal direction the player might nick while rising. Mirrors
/// Baritone's MovementAscend.headBonkClear.
/// </summary>
private bool HasHeadBonkClear(World world)
{
int sx = (int)Math.Floor(ExpectedStart.X);
int sy = (int)Math.Floor(ExpectedStart.Y);
int sz = (int)Math.Floor(ExpectedStart.Z);
// Directly above the source block and each cardinal neighbour at head
// height must be walkable-through so the player never catches a corner.
if (!IsWalkThroughAt(world, sx, sy + 2, sz))
return false;
int[] dx = { 1, -1, 0, 0 };
int[] dz = { 0, 0, 1, -1 };
for (int i = 0; i < 4; i++)
{
if (!IsWalkThroughAt(world, sx + dx[i], sy + 2, sz + dz[i]))
return false;
}
return true;
}
private static bool IsWalkThroughAt(World world, int x, int y, int z)
{
Block block = world.GetBlock(new Location(x, y, z));
return !block.Type.IsSolid();
}
private static float YawDifference(float current, float target)
{
float delta = target - current;

View file

@ -33,6 +33,7 @@ namespace MinecraftClient.Pathing.Execution.Templates
private bool _leftGround;
private bool _carriedGroundEntry;
private bool _releaseForwardLatched;
private readonly SidewallParkourController? _sidewallController;
private const float YawToleranceDeg = 5f;
@ -45,12 +46,18 @@ namespace MinecraftClient.Pathing.Execution.Templates
double dx = segment.End.X - segment.Start.X;
double dz = segment.End.Z - segment.Start.Z;
_horizDist = Math.Sqrt(dx * dx + dz * dz);
if (segment.ParkourProfile == ParkourProfile.Sidewall)
_sidewallController = new SidewallParkourController(segment, nextSegment);
}
public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input, World world)
{
_tickCount++;
if (_sidewallController is not null)
return _sidewallController.Tick(pos, physics, input, world);
double dx = ExpectedEnd.X - pos.X;
double dz = ExpectedEnd.Z - pos.Z;
double dy = ExpectedEnd.Y - pos.Y;
@ -119,10 +126,17 @@ namespace MinecraftClient.Pathing.Execution.Templates
else
minApproachDistance = 0.0;
bool yawAligned = yawDelta < YawToleranceDeg;
bool posReady = approachProgress >= minApproachDistance;
if (yawAligned && posReady)
// Baritone snaps rotation to the exact target every tick and
// the server accepts it without disconnection. For our smooth
// yaw model we only need it at the takeoff tick: snap yaw the
// moment we are positionally ready, so the jump vector is
// aligned regardless of how many ticks we had to turn. This
// prevents mis-jumps when the approach was short and the
// 35 deg/tick smoothing had not finished by the jump tick.
if (posReady)
{
physics.Yaw = targetYaw;
input.Jump = true;
_phase = Phase.Airborne;
}
@ -134,7 +148,10 @@ namespace MinecraftClient.Pathing.Execution.Templates
input.Forward = !turnInPlace;
input.Sprint = !turnInPlace;
}
if (_tickCount > 40)
// Widen the approach/run-up budget. With snap-to-target yaw the
// player aligns in a single tick, but servers may still need a few
// extra ticks of sprint acceleration on long jumps.
if (_tickCount > 80)
return TemplateState.Failed;
break;
@ -237,18 +254,43 @@ namespace MinecraftClient.Pathing.Execution.Templates
{
return TemplateState.Complete;
}
// Baritone-style lenient success: the moment the player is standing
// on the target block (floor center matches), the jump is done. We
// keep the stricter settle checks above as the primary path because
// they capture momentum continuity for downstream segments, but the
// lenient check prevents spurious failures when the player touches
// down slightly off-center or with a small residual slide.
if (_segment.ExitTransition != PathTransitionType.ContinueStraight
&& physics.OnGround
&& LandedInsideTargetBlock(pos))
{
return TemplateState.Complete;
}
break;
}
if (pos.Y < ExpectedEnd.Y - 4.0)
return TemplateState.Failed;
if (_tickCount > 60)
// Baritone's MAX_TICKS_AWAY is 200 ticks (10 seconds) before it gives up
// on a single movement. Short 60-tick windows were too tight for jumps
// that include a run-up, a long airtime, and landing drag settling.
if (_tickCount > 200)
return TemplateState.Failed;
return TemplateState.InProgress;
}
private bool LandedInsideTargetBlock(Location pos)
{
if (!TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, ExpectedEnd))
return false;
// Floor Y must match the target block. Accept anywhere within the block.
return Math.Floor(pos.Y + 1.0E-4) == Math.Floor(ExpectedEnd.Y + 1.0E-4);
}
private bool IsPastTarget(Location pos)
{
double dirX = ExpectedEnd.X - ExpectedStart.X;

View file

@ -1,5 +1,6 @@
using System;
using MinecraftClient.Mapping;
using MinecraftClient.Pathing.Core;
using MinecraftClient.Physics;
namespace MinecraftClient.Pathing.Execution.Templates
@ -10,6 +11,11 @@ namespace MinecraftClient.Pathing.Execution.Templates
private const float MaxYawStepPerTick = 35f;
private const float MaxPitchStepPerTick = 25f;
// Callers that want to force an immediate alignment (e.g. right before
// firing a jump so the takeoff vector matches the target) can use these
// "snap" overloads instead of SmoothYaw/SmoothPitch.
internal static float SnapStep => 360f;
internal static float CalculateYaw(double dx, double dz)
{
float yaw = (float)(-Math.Atan2(dx, dz) / Math.PI * 180.0);
@ -89,6 +95,12 @@ namespace MinecraftClient.Pathing.Execution.Templates
physics.Yaw = SmoothYaw(physics.Yaw, headingYaw);
}
internal static void FaceExitHeading(PlayerPhysics physics, PathSegment segment, PathSegment? nextSegment)
{
float headingYaw = GetExitHeadingYaw(segment, nextSegment);
physics.Yaw = SmoothYaw(physics.Yaw, headingYaw);
}
internal static void ApplyDecision(MovementInput input, TransitionBrakingDecision decision)
{
input.Forward = decision.HoldForward;
@ -137,6 +149,73 @@ namespace MinecraftClient.Pathing.Execution.Templates
return dx * segment.HeadingX + dz * segment.HeadingZ;
}
internal static void GetApproachHeading(PathSegment segment, out int headingX, out int headingZ)
{
if (segment.ParkourProfile == ParkourProfile.Sidewall)
{
double dx = Math.Abs(segment.End.X - segment.Start.X);
double dz = Math.Abs(segment.End.Z - segment.Start.Z);
if (dx > dz)
{
headingX = segment.HeadingX;
headingZ = 0;
}
else
{
headingX = 0;
headingZ = segment.HeadingZ;
}
if (headingX != 0 || headingZ != 0)
return;
}
headingX = segment.HeadingX;
headingZ = segment.HeadingZ;
}
internal static float GetApproachYaw(PathSegment segment)
{
GetApproachHeading(segment, out int headingX, out int headingZ);
return CalculateYaw(headingX, headingZ);
}
internal static double ProgressAlongApproach(Location pos, PathSegment segment)
{
GetApproachHeading(segment, out int headingX, out int headingZ);
return ((pos.X - segment.Start.X) * headingX) + ((pos.Z - segment.Start.Z) * headingZ);
}
internal static float GetSidewallTakeoffYaw(PathSegment segment)
{
float approachYaw = GetApproachYaw(segment);
float targetYaw = CalculateYaw(segment.End.X - segment.Start.X, segment.End.Z - segment.Start.Z);
double major = Math.Max(Math.Abs(segment.End.X - segment.Start.X), Math.Abs(segment.End.Z - segment.Start.Z));
float blend = major switch
{
<= 2.0 => 0.55f,
<= 3.0 => 0.52f,
<= 4.0 => 0.50f,
_ => 0.48f
};
if (major >= 4.0)
blend += 0.04f;
if (segment.End.Y > segment.Start.Y)
blend = Math.Max(0.40f, blend - 0.06f);
else if (segment.End.Y < segment.Start.Y)
{
blend = Math.Min(0.62f, blend + 0.06f);
if (major <= 2.0)
blend = Math.Min(0.66f, blend + 0.05f);
}
return InterpolateYaw(approachYaw, targetYaw, blend);
}
internal static double LateralOffsetFromSegmentLine(Location pos, PathSegment segment)
{
GetNormalizedSegmentDirection(segment, out double dirX, out double dirZ);
@ -160,6 +239,18 @@ namespace MinecraftClient.Pathing.Execution.Templates
return RemainingDistanceAlongSegment(pos, segment) <= distanceThreshold;
}
internal static bool ShouldBiasTowardExitHeading(Location pos, PathSegment segment, PathSegment? nextSegment, double distanceThreshold = 0.35)
{
GetExitHeading(segment, nextSegment, out int headingX, out int headingZ);
if ((headingX == 0 && headingZ == 0)
|| (headingX == segment.HeadingX && headingZ == segment.HeadingZ))
{
return false;
}
return RemainingDistanceAlongSegment(pos, segment) <= distanceThreshold;
}
internal static bool IsSettledOnTargetBlock(Location pos, Location target, PlayerPhysics physics,
double speedThresholdSq = 0.0016)
{
@ -221,6 +312,12 @@ namespace MinecraftClient.Pathing.Execution.Templates
return CalculateYaw(headingX, headingZ);
}
internal static float GetExitHeadingYaw(PathSegment segment, PathSegment? nextSegment)
{
GetExitHeading(segment, nextSegment, out int headingX, out int headingZ);
return CalculateYaw(headingX, headingZ);
}
internal static void GetExitHeading(PathSegment segment, out int headingX, out int headingZ)
{
headingX = segment.ExitHints.DesiredHeadingX;
@ -233,6 +330,20 @@ namespace MinecraftClient.Pathing.Execution.Templates
}
}
internal static void GetExitHeading(PathSegment segment, PathSegment? nextSegment, out int headingX, out int headingZ)
{
if (nextSegment is not null
&& nextSegment.MoveType == MoveType.Parkour
&& nextSegment.ParkourProfile == ParkourProfile.Sidewall)
{
GetApproachHeading(nextSegment, out headingX, out headingZ);
if (headingX != 0 || headingZ != 0)
return;
}
GetExitHeading(segment, out headingX, out headingZ);
}
internal static PlayerPhysics ClonePhysicsForPlanning(PlayerPhysics physics)
{
return new PlayerPhysics
@ -280,5 +391,17 @@ namespace MinecraftClient.Pathing.Execution.Templates
dirX /= len;
dirZ /= len;
}
private static float InterpolateYaw(float from, float to, float factor)
{
float delta = to - from;
while (delta > 180f) delta -= 360f;
while (delta < -180f) delta += 360f;
float result = from + (delta * factor);
while (result < 0f) result += 360f;
while (result >= 360f) result -= 360f;
return result;
}
}
}