From 95b20d9d1c2b5e24545ba51488d68bd673f7c96c Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 19 Apr 2026 17:02:41 +0000 Subject: [PATCH] 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 --- .../Execution/TemplateSimulationRunner.cs | 37 ++- .../Pathing/Execution/PathExecutor.cs | 2 + .../Pathing/Execution/PathSegmentManager.cs | 298 ++++++++++++++++-- .../Execution/Templates/AscendTemplate.cs | 92 +++++- .../Execution/Templates/SprintJumpTemplate.cs | 50 ++- .../Execution/Templates/TemplateHelper.cs | 123 ++++++++ 6 files changed, 554 insertions(+), 48 deletions(-) diff --git a/MinecraftClient.Tests/Pathing/Execution/TemplateSimulationRunner.cs b/MinecraftClient.Tests/Pathing/Execution/TemplateSimulationRunner.cs index 9f716563..ca3781f0 100644 --- a/MinecraftClient.Tests/Pathing/Execution/TemplateSimulationRunner.cs +++ b/MinecraftClient.Tests/Pathing/Execution/TemplateSimulationRunner.cs @@ -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; + } } diff --git a/MinecraftClient/Pathing/Execution/PathExecutor.cs b/MinecraftClient/Pathing/Execution/PathExecutor.cs index 9429dfe2..567dbc49 100644 --- a/MinecraftClient/Pathing/Execution/PathExecutor.cs +++ b/MinecraftClient/Pathing/Execution/PathExecutor.cs @@ -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 segments, Action? debugLog = null, IPathExecutionObserver? observer = null) { diff --git a/MinecraftClient/Pathing/Execution/PathSegmentManager.cs b/MinecraftClient/Pathing/Execution/PathSegmentManager.cs index d9c70e39..ad9d5131 100644 --- a/MinecraftClient/Pathing/Execution/PathSegmentManager.cs +++ b/MinecraftClient/Pathing/Execution/PathSegmentManager.cs @@ -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 /// /// 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. /// 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? _debugLog; private readonly Action? _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? _pendingReplan; + private CancellationTokenSource? _pendingReplanCts; + private Task? _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 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 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(); + } + } } diff --git a/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs index 423b6c36..777b4d96 100644 --- a/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs @@ -7,9 +7,19 @@ namespace MinecraftClient.Pathing.Execution.Templates /// /// 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 <= 1.2) + /// and laterally lined up (sideDist <= 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). /// 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; } + /// + /// 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. + /// + 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; diff --git a/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs index 9e22f61a..29dbc57a 100644 --- a/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs @@ -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; diff --git a/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs b/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs index e79ab8d3..b96a7089 100644 --- a/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs +++ b/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs @@ -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; + } } }