feat: add transition-aware path execution core

This commit is contained in:
BruceChen 2026-04-13 15:35:43 +00:00
parent 7ad0a57a3e
commit 360883acf3
14 changed files with 755 additions and 94 deletions

View file

@ -131,6 +131,22 @@ namespace MinecraftClient.Pathing.Core
CancellationToken ct,
long timeoutMs = 5000)
{
if (goal.IsInGoal(startX, startY, startZ))
{
DebugLog?.Invoke($"[A*] Already in goal at ({startX},{startY},{startZ})");
return new PathResult(
PathStatus.Success,
[new PathNode(startX, startY, startZ)],
nodesExplored: 0,
elapsedMs: 0);
}
if (!IsGoalReachableFootPosition(ctx, goal))
{
DebugLog?.Invoke($"[A*] Goal {goal} is not a reachable foot position");
return PathResult.Fail(nodesExplored: 0, elapsedMs: 0);
}
var sw = Stopwatch.StartNew();
var openSet = new BinaryHeapOpenSet(4096);
var nodeMap = new Dictionary<long, PathNode>(4096);
@ -259,5 +275,21 @@ namespace MinecraftClient.Pathing.Core
path.Reverse();
return path;
}
private static bool IsGoalReachableFootPosition(CalculationContext ctx, IGoal goal)
{
if (goal is not GoalBlock blockGoal)
return true;
if (!ctx.IsChunkLoaded(blockGoal.X, blockGoal.Z))
return true;
if (blockGoal.Y == int.MinValue)
return false;
return ctx.CanWalkOn(blockGoal.X, blockGoal.Y - 1, blockGoal.Z)
&& ctx.CanWalkThrough(blockGoal.X, blockGoal.Y, blockGoal.Z)
&& ctx.CanWalkThrough(blockGoal.X, blockGoal.Y + 1, blockGoal.Z);
}
}
}

View file

@ -10,6 +10,7 @@ namespace MinecraftClient.Pathing.Execution
public required Location End { get; init; }
public required MoveType MoveType { get; init; }
public PathTransitionType ExitTransition { get; init; } = PathTransitionType.FinalStop;
public PathTransitionHints ExitHints { get; init; } = PathTransitionHints.Default;
public bool PreserveSprint { get; init; }
public int HeadingX => Math.Sign(End.X - Start.X);

View file

@ -12,27 +12,9 @@ namespace MinecraftClient.Pathing.Execution
var segments = new List<PathSegment>(Math.Max(0, nodes.Count - 1));
for (int i = 1; i < nodes.Count; i++)
{
PathSegment? next = null;
if (i + 1 < nodes.Count)
{
var nextNode = nodes[i + 1];
var curr = nodes[i];
next = new PathSegment
{
Start = new Location(curr.X + 0.5, curr.Y, curr.Z + 0.5),
End = new Location(nextNode.X + 0.5, nextNode.Y, nextNode.Z + 0.5),
MoveType = nextNode.MoveUsed
};
}
var prev = nodes[i - 1];
var currNode = nodes[i];
var current = new PathSegment
{
Start = new Location(prev.X + 0.5, prev.Y, prev.Z + 0.5),
End = new Location(currNode.X + 0.5, currNode.Y, currNode.Z + 0.5),
MoveType = currNode.MoveUsed
};
PathSegment current = CreatePreview(nodes[i - 1], nodes[i]);
PathSegment? next = i + 1 < nodes.Count ? CreatePreview(nodes[i], nodes[i + 1]) : null;
PathSegment? nextNext = i + 2 < nodes.Count ? CreatePreview(nodes[i + 1], nodes[i + 2]) : null;
PathTransitionType exitTransition = Classify(current, next);
segments.Add(new PathSegment
@ -41,6 +23,7 @@ namespace MinecraftClient.Pathing.Execution
End = current.End,
MoveType = current.MoveType,
ExitTransition = exitTransition,
ExitHints = BuildHints(current, next, nextNext, exitTransition),
PreserveSprint = exitTransition is PathTransitionType.ContinueStraight or PathTransitionType.PrepareJump
});
}
@ -63,5 +46,90 @@ namespace MinecraftClient.Pathing.Execution
return PathTransitionType.Turn;
}
private static PathSegment CreatePreview(PathNode start, PathNode end)
{
return new PathSegment
{
Start = new Location(start.X + 0.5, start.Y, start.Z + 0.5),
End = new Location(end.X + 0.5, end.Y, end.Z + 0.5),
MoveType = end.MoveUsed
};
}
private static PathTransitionHints BuildHints(PathSegment current, PathSegment? next, PathSegment? nextNext,
PathTransitionType exitTransition)
{
if (next is null)
{
return new PathTransitionHints(
DesiredHeadingX: current.HeadingX,
DesiredHeadingZ: current.HeadingZ,
MinExitSpeed: 0.0,
MaxExitSpeed: 0.02,
RequireStableFooting: true,
RequireGrounded: true,
RequireJumpReady: false,
AllowAirBrake: false,
HorizonTicks: 12);
}
if (next.MoveType is MoveType.Parkour or MoveType.Ascend)
{
return new PathTransitionHints(
DesiredHeadingX: next.HeadingX,
DesiredHeadingZ: next.HeadingZ,
MinExitSpeed: next.MoveType == MoveType.Parkour ? 0.10 : 0.0,
MaxExitSpeed: double.PositiveInfinity,
RequireStableFooting: false,
RequireGrounded: true,
RequireJumpReady: true,
AllowAirBrake: false,
HorizonTicks: 10);
}
bool turning = current.HeadingX != next.HeadingX || current.HeadingZ != next.HeadingZ;
bool nextImmediatelyJumps = nextNext is not null
&& nextNext.MoveType is (MoveType.Parkour or MoveType.Ascend);
if (turning)
{
return new PathTransitionHints(
DesiredHeadingX: next.HeadingX,
DesiredHeadingZ: next.HeadingZ,
MinExitSpeed: nextImmediatelyJumps ? 0.05 : 0.0,
MaxExitSpeed: nextImmediatelyJumps ? 0.16 : 0.05,
RequireStableFooting: !nextImmediatelyJumps,
RequireGrounded: true,
RequireJumpReady: nextImmediatelyJumps,
AllowAirBrake: true,
HorizonTicks: 12);
}
if (exitTransition == PathTransitionType.LandingRecovery)
{
return new PathTransitionHints(
DesiredHeadingX: next.HeadingX,
DesiredHeadingZ: next.HeadingZ,
MinExitSpeed: 0.03,
MaxExitSpeed: double.PositiveInfinity,
RequireStableFooting: false,
RequireGrounded: true,
RequireJumpReady: false,
AllowAirBrake: true,
HorizonTicks: 12);
}
return new PathTransitionHints(
DesiredHeadingX: next.HeadingX,
DesiredHeadingZ: next.HeadingZ,
MinExitSpeed: 0.06,
MaxExitSpeed: double.PositiveInfinity,
RequireStableFooting: false,
RequireGrounded: false,
RequireJumpReady: false,
AllowAirBrake: false,
HorizonTicks: 8);
}
}
}

View file

@ -105,6 +105,16 @@ namespace MinecraftClient.Pathing.Execution
using var cts = new CancellationTokenSource();
var result = finder.Calculate(ctx, sx, sy, sz, _goal, cts.Token, 3000);
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;
_goal = null;
return;
}
if (result.Status == PathStatus.Failed || result.Path.Count < 2)
{
_infoLog?.Invoke("[PathMgr] Replan failed -- no path found.");

View file

@ -0,0 +1,25 @@
namespace MinecraftClient.Pathing.Execution
{
public sealed record PathTransitionHints(
int DesiredHeadingX,
int DesiredHeadingZ,
double MinExitSpeed,
double MaxExitSpeed,
bool RequireStableFooting,
bool RequireGrounded,
bool RequireJumpReady,
bool AllowAirBrake,
int HorizonTicks)
{
public static PathTransitionHints Default { get; } = new(
DesiredHeadingX: 0,
DesiredHeadingZ: 0,
MinExitSpeed: 0.0,
MaxExitSpeed: double.PositiveInfinity,
RequireStableFooting: false,
RequireGrounded: false,
RequireJumpReady: false,
AllowAirBrake: false,
HorizonTicks: 8);
}
}

View file

@ -66,7 +66,12 @@ namespace MinecraftClient.Pathing.Execution.Templates
{
TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world);
if (horizDistSq > 0.01 && !decision.HoldBack)
physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw);
{
float groundedYaw = TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment)
? TemplateHelper.GetExitHeadingYaw(_segment)
: targetYaw;
physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, groundedYaw);
}
TemplateHelper.ApplyDecision(input, decision);
if (decision.HoldBack)
@ -99,9 +104,19 @@ namespace MinecraftClient.Pathing.Execution.Templates
}
else
{
input.Forward = true;
if (_needsSprint)
input.Sprint = true;
TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world);
if (_segment.ExitHints.AllowAirBrake)
{
TemplateHelper.ApplyDecision(input, decision);
if (decision.HoldForward && _needsSprint)
input.Sprint = true;
}
else
{
input.Forward = true;
if (_needsSprint)
input.Sprint = true;
}
}
}
}
@ -116,7 +131,8 @@ namespace MinecraftClient.Pathing.Execution.Templates
double remaining = (_segment.End.X - pos.X) * _segment.HeadingX
+ (_segment.End.Z - pos.Z) * _segment.HeadingZ;
return remaining <= 0.55;
return remaining <= 0.55
&& TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, _segment.End);
}
private static float YawDifference(float current, float target)

View file

@ -5,8 +5,13 @@ namespace MinecraftClient.Pathing.Execution.Templates
{
internal static class GroundedSegmentController
{
private const double FinalStopFastCompleteSpeed = 0.08;
internal static void Apply(PathSegment segment, PathSegment? nextSegment, Location pos, PlayerPhysics physics, MovementInput input, World world)
{
if (TemplateHelper.ShouldBiasTowardExitHeading(pos, segment))
TemplateHelper.FaceExitHeading(physics, segment);
TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(segment, nextSegment, pos, physics, world);
TemplateHelper.ApplyDecision(input, decision);
if (decision.HoldBack)
@ -15,11 +20,69 @@ namespace MinecraftClient.Pathing.Execution.Templates
internal static bool ShouldComplete(PathSegment segment, Location pos, PlayerPhysics physics)
{
if (segment.ExitHints.RequireGrounded && !physics.OnGround)
return false;
if (segment.ExitTransition == PathTransitionType.ContinueStraight
&& physics.OnGround
&& TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, segment.End)
&& !TemplateFootingHelper.WillLeaveTargetBlockNextTick(pos, physics, segment.End))
{
return true;
}
if (segment.ExitTransition == PathTransitionType.FinalStop
&& physics.OnGround
&& TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, segment.End)
&& !TemplateFootingHelper.WillLeaveTargetBlockNextTick(pos, physics, segment.End))
{
return TemplateHelper.GetHorizontalSpeed(physics) <= FinalStopFastCompleteSpeed;
}
double exitSpeed = TemplateHelper.ProjectHorizontalSpeedAlongHint(physics, segment);
bool headingReady = TemplateHelper.HeadingPenaltyDegrees(physics.Yaw, segment)
<= (segment.ExitHints.RequireJumpReady ? 8.0 : 15.0);
if (!headingReady)
return false;
if (segment.ExitTransition == PathTransitionType.PrepareJump
&& physics.OnGround
&& segment.ExitHints.RequireJumpReady
&& segment.ExitHints.MinExitSpeed <= 0.0
&& TemplateFootingHelper.IsCenterInsideTargetBlock(pos, segment.End)
&& TemplateHelper.RemainingDistanceAlongSegment(pos, segment) <= 0.30)
{
return true;
}
if (exitSpeed < segment.ExitHints.MinExitSpeed)
return false;
if (exitSpeed > segment.ExitHints.MaxExitSpeed)
return false;
if (segment.ExitHints.RequireStableFooting)
{
return physics.OnGround
&& (segment.ExitTransition == PathTransitionType.FinalStop
? TemplateHelper.IsSettledAtEnd(pos, segment.End, physics)
: TemplateHelper.IsSettledOnTargetBlock(pos, segment.End, physics));
}
if (segment.ExitHints.RequireJumpReady)
{
return physics.OnGround
&& TemplateHelper.HasReachedSegmentEndPlane(pos, segment)
&& exitSpeed >= segment.ExitHints.MinExitSpeed;
}
return segment.ExitTransition switch
{
PathTransitionType.ContinueStraight => TemplateHelper.IsNear(pos, segment.End, horizThresholdSq: 0.09),
PathTransitionType.PrepareJump => TemplateHelper.HasReachedSegmentEndPlane(pos, segment)
&& TemplateHelper.ProjectHorizontalSpeedAlongSegment(physics, segment) > 0.02,
&& exitSpeed > 0.02,
PathTransitionType.FinalStop => physics.OnGround && TemplateHelper.IsSettledAtEnd(pos, segment.End, physics),
_ => physics.OnGround && TemplateHelper.IsSettledOnTargetBlock(pos, segment.End, physics)
};
}

View file

@ -29,7 +29,6 @@ namespace MinecraftClient.Pathing.Execution.Templates
private readonly double _horizDist;
private int _tickCount;
private Phase _phase = Phase.Approach;
private bool _airReleaseCommitted;
private bool _leftGround;
private const float YawToleranceDeg = 5f;
@ -80,7 +79,7 @@ namespace MinecraftClient.Pathing.Execution.Templates
minApproachSq = 0.64; // 0.8 blocks - 3+ ticks of sprint
else if (_horizDist >= 4.0)
minApproachSq = 0.36; // 0.6 blocks - 2-3 ticks of sprint
else if (_horizDist > 2.5)
else if (_horizDist > 3.5)
minApproachSq = 0.09; // 0.3 blocks - 1-2 ticks of sprint
else
minApproachSq = 0.0;
@ -104,17 +103,29 @@ namespace MinecraftClient.Pathing.Execution.Templates
_leftGround = true;
bool pastTarget = IsPastTarget(pos);
bool biasTowardExitInAir = _segment.ExitTransition == PathTransitionType.LandingRecovery
? TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment, distanceThreshold: 1.5)
: TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment);
if (biasTowardExitInAir)
TemplateHelper.FaceExitHeading(physics, _segment);
bool lookaheadAirBrake = TransitionBrakingPlanner.ShouldReleaseForwardInAir(
_segment, _nextSegment, pos, physics, world);
bool releaseInAir = ShouldReleaseInAir(pos, physics, world);
if (_segment.ExitTransition == PathTransitionType.LandingRecovery && releaseInAir)
_airReleaseCommitted = true;
if (_airReleaseCommitted)
releaseInAir = true;
bool earlySoftBrake = _segment.ExitTransition == PathTransitionType.LandingRecovery
&& lookaheadAirBrake
&& !releaseInAir;
if (releaseInAir || pastTarget)
{
input.Forward = false;
input.Sprint = false;
}
else if (earlySoftBrake)
{
input.Forward = true;
input.Sprint = false;
}
else
{
input.Forward = true;
@ -134,6 +145,8 @@ namespace MinecraftClient.Pathing.Execution.Templates
TemplateHelper.ApplyDecision(input, decision);
if (decision.HoldBack)
TemplateHelper.FaceSegmentHeading(physics, _segment);
else if (TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment))
TemplateHelper.FaceExitHeading(physics, _segment);
double horizToleranceLinear = _horizDist >= 3.5 ? 1.5 : 1.0;
double horizToleranceSq = horizToleranceLinear * horizToleranceLinear;
@ -144,7 +157,8 @@ namespace MinecraftClient.Pathing.Execution.Templates
if (_segment.ExitTransition != PathTransitionType.ContinueStraight
&& physics.OnGround
&& TemplateHelper.IsSettledOnTargetBlock(pos, ExpectedEnd, physics))
&& (TemplateHelper.IsSettledOnTargetBlock(pos, ExpectedEnd, physics)
|| IsSettledOnTurnEntryStrip(pos, physics)))
{
return TemplateState.Complete;
}
@ -177,12 +191,16 @@ namespace MinecraftClient.Pathing.Execution.Templates
private bool ShouldReleaseInAir(Location pos, PlayerPhysics physics, World world)
{
if (TransitionBrakingPlanner.ShouldReleaseForwardInAir(_segment, _nextSegment, pos, physics))
return true;
if (_segment.ExitTransition == PathTransitionType.ContinueStraight || physics.OnGround)
return false;
bool plannerWantsRelease = TransitionBrakingPlanner.ShouldReleaseForwardInAir(
_segment, _nextSegment, pos, physics, world);
double remaining = TemplateHelper.RemainingDistanceAlongSegment(pos, _segment);
bool centeredOverLandingBlock = remaining <= 1.2;
if (plannerWantsRelease && centeredOverLandingBlock)
return true;
Location? landingIfHolding = PredictLandingPosition(physics, world, holdForward: true, holdSprint: true);
Location? landingIfReleased = PredictLandingPosition(physics, world, holdForward: false, holdSprint: false);
if (landingIfHolding is null || landingIfReleased is null)
@ -191,15 +209,17 @@ namespace MinecraftClient.Pathing.Execution.Templates
bool holdingStaysInside = TemplateFootingHelper.IsFootprintInsideTargetBlock(landingIfHolding.Value, ExpectedEnd);
bool releasingStaysInside = TemplateFootingHelper.IsFootprintInsideTargetBlock(landingIfReleased.Value, ExpectedEnd);
if (_segment.ExitTransition == PathTransitionType.LandingRecovery && !holdingStaysInside)
if (plannerWantsRelease && releasingStaysInside)
{
return true;
}
return !holdingStaysInside && releasingStaysInside;
}
private Location? PredictLandingPosition(PlayerPhysics physics, World world, bool holdForward, bool holdSprint)
{
PlayerPhysics sim = ClonePhysics(physics);
PlayerPhysics sim = TemplateHelper.ClonePhysicsForPlanning(physics);
var input = new MovementInput
{
Forward = holdForward,
@ -217,36 +237,19 @@ namespace MinecraftClient.Pathing.Execution.Templates
return null;
}
private static PlayerPhysics ClonePhysics(PlayerPhysics physics)
private bool IsSettledOnTurnEntryStrip(Location pos, PlayerPhysics physics)
{
return new PlayerPhysics
{
Position = physics.Position,
DeltaMovement = physics.DeltaMovement,
Yaw = physics.Yaw,
Pitch = physics.Pitch,
OnGround = physics.OnGround,
HorizontalCollision = physics.HorizontalCollision,
VerticalCollision = physics.VerticalCollision,
VerticalCollisionBelow = physics.VerticalCollisionBelow,
FallDistance = physics.FallDistance,
StuckSpeedMultiplier = physics.StuckSpeedMultiplier,
Xxa = physics.Xxa,
Zza = physics.Zza,
Yya = physics.Yya,
Jumping = physics.Jumping,
Sprinting = physics.Sprinting,
Sneaking = physics.Sneaking,
CreativeFlying = physics.CreativeFlying,
InWater = physics.InWater,
IsUnderWater = physics.IsUnderWater,
InLava = physics.InLava,
OnClimbable = physics.OnClimbable,
HasSlowFalling = physics.HasSlowFalling,
HasLevitation = physics.HasLevitation,
LevitationAmplifier = physics.LevitationAmplifier,
MovementSpeed = physics.MovementSpeed
};
if (_segment.ExitTransition != PathTransitionType.LandingRecovery || _nextSegment is null)
return false;
if (_segment.HeadingX == _nextSegment.HeadingX && _segment.HeadingZ == _nextSegment.HeadingZ)
return false;
double horizontalSpeedSq = physics.DeltaMovement.X * physics.DeltaMovement.X
+ physics.DeltaMovement.Z * physics.DeltaMovement.Z;
return TemplateFootingHelper.IsCenterInsideSupportStrip(pos, ExpectedEnd, _nextSegment.End)
&& !TemplateFootingHelper.WillCenterLeaveSupportStripNextTick(pos, physics, ExpectedEnd, _nextSegment.End)
&& horizontalSpeedSq <= 0.0016;
}
private static float YawDifference(float current, float target)

View file

@ -35,6 +35,71 @@ namespace MinecraftClient.Pathing.Execution.Templates
return !IsFootprintInsideTargetBlock(nextPos, target, epsilon);
}
public static bool IsCenterInsideTargetBlock(Location pos, Location target, double epsilon = 1.0E-4)
{
double blockMinX = Math.Floor(target.X);
double blockMaxX = blockMinX + 1.0;
double blockMinZ = Math.Floor(target.Z);
double blockMaxZ = blockMinZ + 1.0;
return pos.X >= blockMinX - epsilon
&& pos.X <= blockMaxX + epsilon
&& pos.Z >= blockMinZ - epsilon
&& pos.Z <= blockMaxZ + epsilon;
}
public static bool WillCenterLeaveTargetBlockNextTick(Location pos, PlayerPhysics physics, Location target, double epsilon = 1.0E-4)
{
Location nextPos = new(
pos.X + physics.DeltaMovement.X,
pos.Y,
pos.Z + physics.DeltaMovement.Z);
return !IsCenterInsideTargetBlock(nextPos, target, epsilon);
}
public static bool IsFootprintInsideSupportStrip(Location pos, Location first, Location second, double epsilon = 1.0E-4)
{
double minX = pos.X - HalfWidth;
double maxX = pos.X + HalfWidth;
double minZ = pos.Z - HalfWidth;
double maxZ = pos.Z + HalfWidth;
GetSupportStripBounds(first, second, out double stripMinX, out double stripMaxX, out double stripMinZ, out double stripMaxZ);
return minX >= stripMinX - epsilon
&& maxX <= stripMaxX + epsilon
&& minZ >= stripMinZ - epsilon
&& maxZ <= stripMaxZ + epsilon;
}
public static bool WillLeaveSupportStripNextTick(Location pos, PlayerPhysics physics, Location first, Location second, double epsilon = 1.0E-4)
{
Location nextPos = new(
pos.X + physics.DeltaMovement.X,
pos.Y,
pos.Z + physics.DeltaMovement.Z);
return !IsFootprintInsideSupportStrip(nextPos, first, second, epsilon);
}
public static bool IsCenterInsideSupportStrip(Location pos, Location first, Location second, double epsilon = 1.0E-4)
{
GetSupportStripBounds(first, second, out double stripMinX, out double stripMaxX, out double stripMinZ, out double stripMaxZ);
return pos.X >= stripMinX - epsilon
&& pos.X <= stripMaxX + epsilon
&& pos.Z >= stripMinZ - epsilon
&& pos.Z <= stripMaxZ + epsilon;
}
public static bool WillCenterLeaveSupportStripNextTick(Location pos, PlayerPhysics physics, Location first, Location second, double epsilon = 1.0E-4)
{
Location nextPos = new(
pos.X + physics.DeltaMovement.X,
pos.Y,
pos.Z + physics.DeltaMovement.Z);
return !IsCenterInsideSupportStrip(nextPos, first, second, epsilon);
}
public static bool WillCrossSupportExitNextTick(Location pos, PlayerPhysics physics, PathSegment segment, double epsilon = 1.0E-4)
{
double nextX = pos.X + physics.DeltaMovement.X;
@ -56,5 +121,19 @@ namespace MinecraftClient.Pathing.Execution.Templates
return false;
}
private static void GetSupportStripBounds(Location first, Location second,
out double minX, out double maxX, out double minZ, out double maxZ)
{
double firstMinX = Math.Floor(first.X);
double secondMinX = Math.Floor(second.X);
double firstMinZ = Math.Floor(first.Z);
double secondMinZ = Math.Floor(second.Z);
minX = Math.Min(firstMinX, secondMinX);
maxX = Math.Max(firstMinX, secondMinX) + 1.0;
minZ = Math.Min(firstMinZ, secondMinZ);
maxZ = Math.Max(firstMinZ, secondMinZ) + 1.0;
}
}
}

View file

@ -83,6 +83,12 @@ namespace MinecraftClient.Pathing.Execution.Templates
physics.Yaw = SmoothYaw(physics.Yaw, headingYaw);
}
internal static void FaceExitHeading(PlayerPhysics physics, PathSegment segment)
{
float headingYaw = GetExitHeadingYaw(segment);
physics.Yaw = SmoothYaw(physics.Yaw, headingYaw);
}
internal static void ApplyDecision(MovementInput input, TransitionBrakingDecision decision)
{
input.Forward = decision.HoldForward;
@ -104,6 +110,45 @@ namespace MinecraftClient.Pathing.Execution.Templates
return physics.DeltaMovement.X * dirX + physics.DeltaMovement.Z * dirZ;
}
internal static double ProjectHorizontalSpeedAlongHint(PlayerPhysics physics, PathSegment segment)
{
GetExitHeading(segment, out int headingX, out int headingZ);
return ProjectHorizontalSpeedAlongHeading(physics, headingX, headingZ);
}
internal static double ProjectHorizontalSpeedAlongHeading(PlayerPhysics physics, int headingX, int headingZ)
{
if (headingX == 0 && headingZ == 0)
return GetHorizontalSpeed(physics);
return physics.DeltaMovement.X * headingX + physics.DeltaMovement.Z * headingZ;
}
internal static double GetHorizontalSpeed(PlayerPhysics physics)
{
return Math.Sqrt(physics.DeltaMovement.X * physics.DeltaMovement.X
+ physics.DeltaMovement.Z * physics.DeltaMovement.Z);
}
internal static double RemainingDistanceAlongSegment(Location pos, PathSegment segment)
{
double dx = segment.End.X - pos.X;
double dz = segment.End.Z - pos.Z;
return dx * segment.HeadingX + dz * segment.HeadingZ;
}
internal static bool ShouldBiasTowardExitHeading(Location pos, PathSegment segment, double distanceThreshold = 0.35)
{
GetExitHeading(segment, 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)
{
@ -120,11 +165,88 @@ namespace MinecraftClient.Pathing.Execution.Templates
if (IsSettledOnTargetBlock(pos, target, physics, speedThresholdSq))
return true;
double dx = target.X - pos.X;
double dz = target.Z - pos.Z;
double horizontalSpeedSq = physics.DeltaMovement.X * physics.DeltaMovement.X
+ physics.DeltaMovement.Z * physics.DeltaMovement.Z;
return dx * dx + dz * dz <= horizThresholdSq && horizontalSpeedSq <= speedThresholdSq;
if (horizontalSpeedSq > speedThresholdSq)
return false;
if (TemplateFootingHelper.IsCenterInsideTargetBlock(pos, target)
&& !TemplateFootingHelper.WillCenterLeaveTargetBlockNextTick(pos, physics, target))
{
return true;
}
double dx = target.X - pos.X;
double dz = target.Z - pos.Z;
return dx * dx + dz * dz <= horizThresholdSq;
}
internal static double HeadingPenaltyDegrees(float yaw, PathSegment segment)
{
GetExitHeading(segment, out int headingX, out int headingZ);
return HeadingPenaltyDegrees(yaw, headingX, headingZ);
}
internal static double HeadingPenaltyDegrees(float yaw, int headingX, int headingZ)
{
if (headingX == 0 && headingZ == 0)
return 0.0;
float targetYaw = CalculateYaw(headingX, headingZ);
float delta = targetYaw - yaw;
while (delta > 180f) delta -= 360f;
while (delta < -180f) delta += 360f;
return Math.Abs(delta);
}
internal static float GetExitHeadingYaw(PathSegment segment)
{
GetExitHeading(segment, 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;
headingZ = segment.ExitHints.DesiredHeadingZ;
if (headingX == 0 && headingZ == 0)
{
headingX = segment.HeadingX;
headingZ = segment.HeadingZ;
}
}
internal static PlayerPhysics ClonePhysicsForPlanning(PlayerPhysics physics)
{
return new PlayerPhysics
{
Position = physics.Position,
DeltaMovement = physics.DeltaMovement,
Yaw = physics.Yaw,
Pitch = physics.Pitch,
OnGround = physics.OnGround,
HorizontalCollision = physics.HorizontalCollision,
VerticalCollision = physics.VerticalCollision,
VerticalCollisionBelow = physics.VerticalCollisionBelow,
FallDistance = physics.FallDistance,
StuckSpeedMultiplier = physics.StuckSpeedMultiplier,
Xxa = physics.Xxa,
Zza = physics.Zza,
Yya = physics.Yya,
Jumping = physics.Jumping,
Sprinting = physics.Sprinting,
Sneaking = physics.Sneaking,
CreativeFlying = physics.CreativeFlying,
InWater = physics.InWater,
IsUnderWater = physics.IsUnderWater,
InLava = physics.InLava,
OnClimbable = physics.OnClimbable,
HasSlowFalling = physics.HasSlowFalling,
HasLevitation = physics.HasLevitation,
LevitationAmplifier = physics.LevitationAmplifier,
MovementSpeed = physics.MovementSpeed
};
}
private static void GetNormalizedSegmentDirection(PathSegment segment, out double dirX, out double dirZ)

View file

@ -35,7 +35,9 @@ namespace MinecraftClient.Pathing.Execution.Templates
double dx = ExpectedEnd.X - pos.X;
double dz = ExpectedEnd.Z - pos.Z;
double dy = ExpectedEnd.Y - pos.Y;
float targetYaw = TemplateHelper.CalculateYaw(dx, dz);
float targetYaw = TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment)
? TemplateHelper.GetExitHeadingYaw(_segment)
: TemplateHelper.CalculateYaw(dx, dz);
float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz);
physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw);
physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch);

View file

@ -1,5 +1,6 @@
using System;
using MinecraftClient.Mapping;
using MinecraftClient.Pathing.Execution.Templates;
using MinecraftClient.Physics;
namespace MinecraftClient.Pathing.Execution
@ -15,39 +16,61 @@ namespace MinecraftClient.Pathing.Execution
public static TransitionBrakingDecision Plan(PathSegment current, PathSegment? next, Location pos, PlayerPhysics physics, World world)
{
if (current.ExitTransition is PathTransitionType.ContinueStraight or PathTransitionType.PrepareJump)
return TransitionBrakingDecision.CarryMomentum(current.PreserveSprint);
double remaining = RemainingDistanceAlongSegment(current, pos);
double forwardSpeed = Math.Max(0.0, ProjectHorizontalSpeedAlongHeading(physics, current.HeadingX, current.HeadingZ));
double coastStopDistance = EstimateGroundStopDistance(physics, world, current.HeadingX, current.HeadingZ, applyBackBrake: false);
double hardBrakeDistance = EstimateGroundStopDistance(physics, world, current.HeadingX, current.HeadingZ, applyBackBrake: true);
bool landingNeedsTurnBrake = current.ExitTransition == PathTransitionType.LandingRecovery
if (physics.OnGround
&& current.ExitTransition == PathTransitionType.LandingRecovery
&& next is not null
&& !HasSameHeading(current, next);
if (current.ExitTransition == PathTransitionType.FinalStop)
&& !HasSameHeading(current, next))
{
if (remaining < 0.0)
double remaining = RemainingDistanceAlongSegment(current, pos);
double forwardSpeed = Math.Max(0.0,
ProjectHorizontalSpeedAlongHeading(physics, current.HeadingX, current.HeadingZ));
double maxExitSpeed = !double.IsPositiveInfinity(current.ExitHints.MaxExitSpeed)
? current.ExitHints.MaxExitSpeed
: 0.035;
double coastStopDistance = EstimateGroundStopDistance(
physics, world, current.HeadingX, current.HeadingZ, applyBackBrake: false);
double hardBrakeDistance = EstimateGroundStopDistance(
physics, world, current.HeadingX, current.HeadingZ, applyBackBrake: true);
if (TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, current.End)
&& forwardSpeed <= maxExitSpeed)
{
return TransitionBrakingDecision.Coast;
}
if (remaining < 0.0 && forwardSpeed > maxExitSpeed)
return TransitionBrakingDecision.Brake;
if (forwardSpeed > GroundSpeedThreshold && remaining <= hardBrakeDistance + FinalBrakeLead)
if (forwardSpeed > GroundSpeedThreshold && remaining <= hardBrakeDistance + TurnBrakeLead)
return TransitionBrakingDecision.Brake;
if (forwardSpeed <= GroundSpeedThreshold && remaining > 0.0)
return TransitionBrakingDecision.CarryMomentum(preserveSprint: false);
if (remaining <= coastStopDistance + FinalStopLead)
return TransitionBrakingDecision.Coast;
}
if ((current.ExitTransition == PathTransitionType.Turn || landingNeedsTurnBrake)
&& remaining <= hardBrakeDistance + TurnBrakeLead)
TransitionInputProfile profile;
if (physics.OnGround)
{
return TransitionBrakingDecision.Brake;
profile = TransitionLookaheadEvaluator.ChooseGroundProfile(current, pos, physics, world);
}
else
{
if (!current.ExitHints.AllowAirBrake)
return TransitionBrakingDecision.CarryMomentum(current.PreserveSprint);
profile = TransitionLookaheadEvaluator.ChooseAirProfile(current, pos, physics, world);
}
if (remaining <= coastStopDistance + FinalStopLead)
return TransitionBrakingDecision.Coast;
return TransitionBrakingDecision.CarryMomentum(current.PreserveSprint);
return profile switch
{
TransitionInputProfile.Carry => TransitionBrakingDecision.CarryMomentum(current.PreserveSprint),
TransitionInputProfile.Coast => TransitionBrakingDecision.Coast,
TransitionInputProfile.Brake => TransitionBrakingDecision.Brake,
TransitionInputProfile.AirHoldForward => TransitionBrakingDecision.CarryMomentum(current.PreserveSprint),
TransitionInputProfile.AirRelease => TransitionBrakingDecision.Coast,
TransitionInputProfile.AirBrake => TransitionBrakingDecision.Brake,
_ => TransitionBrakingDecision.Coast
};
}
public static bool ShouldReleaseForwardInAir(PathSegment current, PathSegment? next, Location pos, PlayerPhysics physics)
@ -61,6 +84,15 @@ namespace MinecraftClient.Pathing.Execution
return remaining <= forwardSpeed + AirReleaseLead;
}
public static bool ShouldReleaseForwardInAir(PathSegment current, PathSegment? next, Location pos, PlayerPhysics physics, World world)
{
if (!current.ExitHints.AllowAirBrake)
return false;
TransitionInputProfile profile = TransitionLookaheadEvaluator.ChooseAirProfile(current, pos, physics, world);
return profile is TransitionInputProfile.AirRelease or TransitionInputProfile.AirBrake;
}
public static double EstimateGroundStopDistance(PlayerPhysics physics, World world, int headingX, int headingZ, bool applyBackBrake)
{
if (!physics.OnGround)

View file

@ -0,0 +1,12 @@
namespace MinecraftClient.Pathing.Execution
{
public enum TransitionInputProfile
{
Carry,
Coast,
Brake,
AirHoldForward,
AirRelease,
AirBrake
}
}

View file

@ -0,0 +1,196 @@
using System;
using MinecraftClient.Mapping;
using MinecraftClient.Pathing.Execution.Templates;
using MinecraftClient.Physics;
namespace MinecraftClient.Pathing.Execution
{
public static class TransitionLookaheadEvaluator
{
public static TransitionInputProfile ChooseGroundProfile(PathSegment segment, Location pos, PlayerPhysics physics, World world)
{
double remaining = TemplateHelper.RemainingDistanceAlongSegment(pos, segment);
double forwardSpeed = Math.Max(0.0,
TemplateHelper.ProjectHorizontalSpeedAlongHeading(physics, segment.HeadingX, segment.HeadingZ));
bool requiresJumpEntry = segment.ExitHints.RequireJumpReady
|| segment.ExitTransition == PathTransitionType.PrepareJump;
if (segment.ExitTransition == PathTransitionType.ContinueStraight && !requiresJumpEntry)
return TransitionInputProfile.Carry;
if (requiresJumpEntry)
return TransitionInputProfile.Carry;
bool requiresSlowEntry = segment.ExitHints.RequireStableFooting
|| segment.ExitTransition is PathTransitionType.FinalStop or PathTransitionType.Turn
|| (segment.ExitTransition == PathTransitionType.LandingRecovery
&& (segment.ExitHints.AllowAirBrake || IsFiniteSpeedCap(segment)));
if (!requiresSlowEntry)
return TransitionInputProfile.Carry;
double maxExitSpeed = GetTargetMaxExitSpeed(segment);
double hardBrakeDistance = TransitionBrakingPlanner.EstimateGroundStopDistance(
physics, world, segment.HeadingX, segment.HeadingZ, applyBackBrake: true);
double coastStopDistance = TransitionBrakingPlanner.EstimateGroundStopDistance(
physics, world, segment.HeadingX, segment.HeadingZ, applyBackBrake: false);
if (remaining < 0.0)
return TransitionInputProfile.Brake;
if (forwardSpeed > maxExitSpeed && remaining <= hardBrakeDistance + 0.10)
return TransitionInputProfile.Brake;
if (forwardSpeed <= maxExitSpeed && remaining > 0.0)
return TransitionInputProfile.Carry;
if (remaining <= coastStopDistance + 0.06)
return TransitionInputProfile.Coast;
return TransitionInputProfile.Carry;
}
public static TransitionInputProfile ChooseAirProfile(PathSegment segment, Location pos, PlayerPhysics physics, World world)
{
if (!segment.ExitHints.AllowAirBrake)
return TransitionInputProfile.AirHoldForward;
TransitionInputProfile[] candidates =
[
TransitionInputProfile.AirHoldForward,
TransitionInputProfile.AirRelease,
TransitionInputProfile.AirBrake
];
return ChooseBest(segment, pos, physics, world, candidates);
}
private static TransitionInputProfile ChooseBest(PathSegment segment, Location pos, PlayerPhysics physics, World world,
TransitionInputProfile[] candidates)
{
TransitionInputProfile best = candidates[0];
double bestScore = double.PositiveInfinity;
foreach (TransitionInputProfile candidate in candidates)
{
double score = Score(segment, pos, physics, world, candidate);
if (score < bestScore)
{
best = candidate;
bestScore = score;
}
}
return best;
}
private static double Score(PathSegment segment, Location pos, PlayerPhysics physics, World world, TransitionInputProfile candidate)
{
PlayerPhysics sim = TemplateHelper.ClonePhysicsForPlanning(physics);
sim.Position = new Vec3d(pos.X, pos.Y, pos.Z);
var input = new MovementInput();
Location simPos = pos;
for (int tick = 0; tick < segment.ExitHints.HorizonTicks; tick++)
{
if (TemplateHelper.ShouldBiasTowardExitHeading(simPos, segment))
TemplateHelper.FaceExitHeading(sim, segment);
input.Reset();
ApplyCandidateInput(input, candidate, segment);
sim.ApplyInput(input);
sim.Tick(world);
simPos = new Location(sim.Position.X, sim.Position.Y, sim.Position.Z);
}
double score = 0.0;
double exitSpeed = TemplateHelper.ProjectHorizontalSpeedAlongHint(sim, segment);
double horizontalSpeed = TemplateHelper.GetHorizontalSpeed(sim);
if (segment.ExitHints.RequireGrounded && !sim.OnGround)
score += 1000.0;
if (segment.ExitHints.RequireStableFooting
&& !TemplateHelper.IsSettledOnTargetBlock(simPos, segment.End, sim))
{
score += 1000.0;
}
if (segment.ExitHints.RequireStableFooting && !sim.OnGround)
{
double remaining = TemplateHelper.RemainingDistanceAlongSegment(simPos, segment);
if (remaining > 0.0)
score += 2000.0 + remaining * 500.0;
if (simPos.Y < segment.End.Y)
score += 2000.0 + (segment.End.Y - simPos.Y) * 500.0;
}
if (exitSpeed < segment.ExitHints.MinExitSpeed)
score += (segment.ExitHints.MinExitSpeed - exitSpeed) * 200.0;
if (exitSpeed > segment.ExitHints.MaxExitSpeed)
score += (exitSpeed - segment.ExitHints.MaxExitSpeed) * 200.0;
score += TemplateHelper.HeadingPenaltyDegrees(sim.Yaw, segment);
if (segment.ExitHints.RequireStableFooting)
{
double dx = segment.End.X - simPos.X;
double dz = segment.End.Z - simPos.Z;
score += (dx * dx + dz * dz) * 20.0;
}
else
{
score += Math.Abs(TemplateHelper.RemainingDistanceAlongSegment(simPos, segment)) * 10.0;
}
if (segment.ExitHints.RequireJumpReady && horizontalSpeed < segment.ExitHints.MinExitSpeed)
score += 250.0;
return score;
}
private static void ApplyCandidateInput(MovementInput input, TransitionInputProfile candidate, PathSegment segment)
{
switch (candidate)
{
case TransitionInputProfile.Carry:
case TransitionInputProfile.AirHoldForward:
input.Forward = true;
input.Sprint = segment.PreserveSprint || segment.ExitHints.RequireJumpReady;
break;
case TransitionInputProfile.Brake:
case TransitionInputProfile.AirBrake:
input.Back = true;
break;
case TransitionInputProfile.Coast:
case TransitionInputProfile.AirRelease:
default:
break;
}
}
private static bool IsFiniteSpeedCap(PathSegment segment)
{
return !double.IsPositiveInfinity(segment.ExitHints.MaxExitSpeed);
}
private static double GetTargetMaxExitSpeed(PathSegment segment)
{
if (IsFiniteSpeedCap(segment))
return segment.ExitHints.MaxExitSpeed;
return segment.ExitTransition switch
{
PathTransitionType.FinalStop => 0.03,
PathTransitionType.Turn or PathTransitionType.LandingRecovery => 0.035,
_ => double.PositiveInfinity
};
}
}
}