diff --git a/MinecraftClient/Pathing/Core/AStarPathFinder.cs b/MinecraftClient/Pathing/Core/AStarPathFinder.cs index dd2d7a4e..c7dfb150 100644 --- a/MinecraftClient/Pathing/Core/AStarPathFinder.cs +++ b/MinecraftClient/Pathing/Core/AStarPathFinder.cs @@ -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(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); + } } } diff --git a/MinecraftClient/Pathing/Execution/PathSegment.cs b/MinecraftClient/Pathing/Execution/PathSegment.cs index c39e88de..f3b163a9 100644 --- a/MinecraftClient/Pathing/Execution/PathSegment.cs +++ b/MinecraftClient/Pathing/Execution/PathSegment.cs @@ -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); diff --git a/MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs b/MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs index 36db785d..4370068e 100644 --- a/MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs +++ b/MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs @@ -12,27 +12,9 @@ namespace MinecraftClient.Pathing.Execution var segments = new List(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); + } } } diff --git a/MinecraftClient/Pathing/Execution/PathSegmentManager.cs b/MinecraftClient/Pathing/Execution/PathSegmentManager.cs index d3911ae9..41c917b7 100644 --- a/MinecraftClient/Pathing/Execution/PathSegmentManager.cs +++ b/MinecraftClient/Pathing/Execution/PathSegmentManager.cs @@ -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."); diff --git a/MinecraftClient/Pathing/Execution/PathTransitionHints.cs b/MinecraftClient/Pathing/Execution/PathTransitionHints.cs new file mode 100644 index 00000000..28a58e64 --- /dev/null +++ b/MinecraftClient/Pathing/Execution/PathTransitionHints.cs @@ -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); + } +} diff --git a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs index 5337decc..4348faf3 100644 --- a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs @@ -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) diff --git a/MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs b/MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs index 23d2fafb..62a642b4 100644 --- a/MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs +++ b/MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs @@ -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) }; } diff --git a/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs index 453d5e12..b2f176cf 100644 --- a/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs @@ -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) diff --git a/MinecraftClient/Pathing/Execution/Templates/TemplateFootingHelper.cs b/MinecraftClient/Pathing/Execution/Templates/TemplateFootingHelper.cs index 8966e387..7d86ce2e 100644 --- a/MinecraftClient/Pathing/Execution/Templates/TemplateFootingHelper.cs +++ b/MinecraftClient/Pathing/Execution/Templates/TemplateFootingHelper.cs @@ -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; + } } } diff --git a/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs b/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs index e01fdc05..dedf42ef 100644 --- a/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs +++ b/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs @@ -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) diff --git a/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs index 5e8a1d47..1483ca00 100644 --- a/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs @@ -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); diff --git a/MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs b/MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs index 89cab765..a624289c 100644 --- a/MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs +++ b/MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs @@ -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) diff --git a/MinecraftClient/Pathing/Execution/TransitionInputProfile.cs b/MinecraftClient/Pathing/Execution/TransitionInputProfile.cs new file mode 100644 index 00000000..2f0853e6 --- /dev/null +++ b/MinecraftClient/Pathing/Execution/TransitionInputProfile.cs @@ -0,0 +1,12 @@ +namespace MinecraftClient.Pathing.Execution +{ + public enum TransitionInputProfile + { + Carry, + Coast, + Brake, + AirHoldForward, + AirRelease, + AirBrake + } +} diff --git a/MinecraftClient/Pathing/Execution/TransitionLookaheadEvaluator.cs b/MinecraftClient/Pathing/Execution/TransitionLookaheadEvaluator.cs new file mode 100644 index 00000000..d3ad6626 --- /dev/null +++ b/MinecraftClient/Pathing/Execution/TransitionLookaheadEvaluator.cs @@ -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 + }; + } + } +}