From 3b4e552d70fb5e240429ed99b810e3c2d9e51a3a Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 12 Apr 2026 18:46:42 +0800 Subject: [PATCH] feat: add transition-aware path execution braking --- .../MinecraftClient.Tests.csproj | 25 ++++ .../Pathing/Execution/FlatWorldTestBuilder.cs | 51 +++++++ .../Execution/PathExecutorCompletionTests.cs | 41 ++++++ .../Execution/PathSegmentBuilderTests.cs | 64 ++++++++ .../Pathing/Execution/TemplateBrakingTests.cs | 79 ++++++++++ .../TransitionBrakingPlannerTests.cs | 110 ++++++++++++++ MinecraftClient.sln | 14 ++ .../Execution/ActionTemplateFactory.cs | 16 +- .../Pathing/Execution/IActionTemplate.cs | 2 +- .../Pathing/Execution/PathExecutor.cs | 12 +- .../Pathing/Execution/PathSegment.cs | 24 +-- .../Pathing/Execution/PathSegmentBuilder.cs | 67 +++++++++ .../Pathing/Execution/PathSegmentManager.cs | 6 +- .../Pathing/Execution/PathTransitionType.cs | 11 ++ .../Execution/Templates/AscendTemplate.cs | 34 ++++- .../Execution/Templates/ClimbTemplate.cs | 10 +- .../Execution/Templates/DescendTemplate.cs | 47 ++++-- .../Execution/Templates/FallTemplate.cs | 19 ++- .../Execution/Templates/SprintJumpTemplate.cs | 139 ++++++++++++++---- .../Execution/Templates/TemplateHelper.cs | 24 +++ .../Execution/Templates/WalkTemplate.cs | 29 ++-- .../Execution/TransitionBrakingDecision.cs | 14 ++ .../Execution/TransitionBrakingPlanner.cs | 107 ++++++++++++++ 23 files changed, 837 insertions(+), 108 deletions(-) create mode 100644 MinecraftClient.Tests/MinecraftClient.Tests.csproj create mode 100644 MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilder.cs create mode 100644 MinecraftClient.Tests/Pathing/Execution/PathExecutorCompletionTests.cs create mode 100644 MinecraftClient.Tests/Pathing/Execution/PathSegmentBuilderTests.cs create mode 100644 MinecraftClient.Tests/Pathing/Execution/TemplateBrakingTests.cs create mode 100644 MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs create mode 100644 MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs create mode 100644 MinecraftClient/Pathing/Execution/PathTransitionType.cs create mode 100644 MinecraftClient/Pathing/Execution/TransitionBrakingDecision.cs create mode 100644 MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs diff --git a/MinecraftClient.Tests/MinecraftClient.Tests.csproj b/MinecraftClient.Tests/MinecraftClient.Tests.csproj new file mode 100644 index 00000000..937d0e56 --- /dev/null +++ b/MinecraftClient.Tests/MinecraftClient.Tests.csproj @@ -0,0 +1,25 @@ + + + net10.0 + enable + enable + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilder.cs b/MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilder.cs new file mode 100644 index 00000000..5f282b29 --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilder.cs @@ -0,0 +1,51 @@ +using System; +using System.Threading; +using MinecraftClient.Mapping; + +namespace MinecraftClient.Tests.Pathing.Execution; + +internal static class FlatWorldTestBuilder +{ + private static readonly Lock InitLock = new(); + private static bool _defaultsLoaded; + + public static World CreateStoneFloor(int floorY = 79, int min = -32, int max = 32) + { + EnsureDefaultDimensionsLoaded(); + World.SetDimension("minecraft:overworld"); + + var world = new World(); + int minChunk = (int)Math.Floor(min / 16.0); + int maxChunk = (int)Math.Floor(max / 16.0); + + for (int chunkX = minChunk; chunkX <= maxChunk; chunkX++) + { + for (int chunkZ = minChunk; chunkZ <= maxChunk; chunkZ++) + { + world[chunkX, chunkZ] = new ChunkColumn(24) { FullyLoaded = true }; + } + } + + for (int x = min; x <= max; x++) + { + for (int z = min; z <= max; z++) + { + world.SetBlock(new Location(x, floorY, z), new Block(1)); + } + } + + return world; + } + + private static void EnsureDefaultDimensionsLoaded() + { + lock (InitLock) + { + if (_defaultsLoaded) + return; + + World.LoadDefaultDimensions1206Plus(); + _defaultsLoaded = true; + } + } +} diff --git a/MinecraftClient.Tests/Pathing/Execution/PathExecutorCompletionTests.cs b/MinecraftClient.Tests/Pathing/Execution/PathExecutorCompletionTests.cs new file mode 100644 index 00000000..cf8920df --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/PathExecutorCompletionTests.cs @@ -0,0 +1,41 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Physics; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class PathExecutorCompletionTests +{ + [Fact] + public void Tick_ClearsMovementInput_WhenSegmentCompletes() + { + var executor = new PathExecutor(new List + { + new() + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 80, 0.5), + MoveType = MoveType.Traverse + } + }); + + var physics = new PlayerPhysics + { + Yaw = 270f, + Pitch = 0f + }; + var input = new MovementInput(); + var pos = new Location(1.48, 80, 0.5); + World world = FlatWorldTestBuilder.CreateStoneFloor(); + + var state = executor.Tick(pos, physics, input, world); + + Assert.Equal(PathExecutorState.Complete, state); + Assert.False(input.Forward); + Assert.False(input.Sprint); + Assert.False(input.Jump); + Assert.False(input.Back); + } +} diff --git a/MinecraftClient.Tests/Pathing/Execution/PathSegmentBuilderTests.cs b/MinecraftClient.Tests/Pathing/Execution/PathSegmentBuilderTests.cs new file mode 100644 index 00000000..a15dc070 --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/PathSegmentBuilderTests.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class PathSegmentBuilderTests +{ + [Fact] + public void FromPath_AnnotatesStraightTraverse_AsContinueStraight() + { + var nodes = BuildNodes( + (0, 80, 0, MoveType.Traverse), + (1, 80, 0, MoveType.Traverse), + (2, 80, 0, MoveType.Traverse)); + + List segments = PathSegmentBuilder.FromPath(nodes); + + Assert.Equal(PathTransitionType.ContinueStraight, segments[0].ExitTransition); + Assert.True(segments[0].PreserveSprint); + } + + [Fact] + public void FromPath_AnnotatesOrthogonalTraverse_AsTurn() + { + var nodes = BuildNodes( + (0, 80, 0, MoveType.Traverse), + (1, 80, 0, MoveType.Traverse), + (1, 80, 1, MoveType.Traverse)); + + List segments = PathSegmentBuilder.FromPath(nodes); + + Assert.Equal(PathTransitionType.Turn, segments[0].ExitTransition); + Assert.False(segments[0].PreserveSprint); + } + + [Fact] + public void FromPath_AnnotatesTraverseIntoParkour_AsPrepareJump() + { + var nodes = BuildNodes( + (120, 80, 110, MoveType.Traverse), + (121, 80, 110, MoveType.Traverse), + (123, 80, 110, MoveType.Parkour)); + + List segments = PathSegmentBuilder.FromPath(nodes); + + Assert.Equal(PathTransitionType.PrepareJump, segments[0].ExitTransition); + Assert.True(segments[0].PreserveSprint); + } + + private static List BuildNodes(params (int x, int y, int z, MoveType moveUsed)[] raw) + { + var result = new List(raw.Length); + for (int i = 0; i < raw.Length; i++) + { + var node = new PathNode(raw[i].x, raw[i].y, raw[i].z); + if (i > 0) + node.MoveUsed = raw[i].moveUsed; + result.Add(node); + } + return result; + } +} diff --git a/MinecraftClient.Tests/Pathing/Execution/TemplateBrakingTests.cs b/MinecraftClient.Tests/Pathing/Execution/TemplateBrakingTests.cs new file mode 100644 index 00000000..4e7c4436 --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/TemplateBrakingTests.cs @@ -0,0 +1,79 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Pathing.Execution.Templates; +using MinecraftClient.Physics; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class TemplateBrakingTests +{ + [Fact] + public void WalkTemplate_BackBrakes_WhenFinalStopIsTooClose() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(); + var segment = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 80, 0.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop, + PreserveSprint = false + }; + + var template = new WalkTemplate(segment, null); + var physics = new PlayerPhysics + { + Position = new Vec3d(1.38, 80.0, 0.5), + DeltaMovement = new Vec3d(0.156, 0.0, 0.0), + OnGround = true, + Yaw = 270f + }; + var input = new MovementInput(); + + TemplateState state = template.Tick(new Location(1.38, 80, 0.5), physics, input, world); + + Assert.Equal(TemplateState.InProgress, state); + Assert.False(input.Forward); + Assert.False(input.Sprint); + Assert.True(input.Back); + } + + [Fact] + public void WalkTemplate_KeepsForward_WhenTransitionContinuesStraight() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(); + var current = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 80, 0.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.ContinueStraight, + PreserveSprint = true + }; + var next = new PathSegment + { + Start = new Location(1.5, 80, 0.5), + End = new Location(2.5, 80, 0.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop + }; + + var template = new WalkTemplate(current, next); + var physics = new PlayerPhysics + { + Position = new Vec3d(1.10, 80.0, 0.5), + DeltaMovement = new Vec3d(0.140, 0.0, 0.0), + OnGround = true, + Yaw = 270f + }; + var input = new MovementInput(); + + TemplateState state = template.Tick(new Location(1.10, 80, 0.5), physics, input, world); + + Assert.Equal(TemplateState.InProgress, state); + Assert.True(input.Forward); + Assert.True(input.Sprint); + } +} diff --git a/MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs b/MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs new file mode 100644 index 00000000..262c2c17 --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs @@ -0,0 +1,110 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Physics; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class TransitionBrakingPlannerTests +{ + [Fact] + public void Plan_ReturnsCarryMomentum_ForContinueStraight() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(); + var physics = CreatePhysics(0.156, 0.0, onGround: true); + var current = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 80, 0.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.ContinueStraight, + PreserveSprint = true + }; + + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(current, null, new Location(1.05, 80, 0.5), physics, world); + + Assert.True(decision.HoldForward); + Assert.True(decision.HoldSprint); + Assert.False(decision.HoldBack); + } + + [Fact] + public void Plan_BackBrakes_ForFinalStop_WhenRemainingRunwayIsTooShort() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(); + var physics = CreatePhysics(0.156, 0.0, onGround: true); + var current = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 80, 0.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop, + PreserveSprint = false + }; + + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(current, null, new Location(1.38, 80, 0.5), physics, world); + + Assert.False(decision.HoldForward); + Assert.False(decision.HoldSprint); + Assert.True(decision.HoldBack); + } + + [Fact] + public void Plan_NudgesForward_ForFinalStop_WhenAlreadySlowButStillShort() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(); + var physics = CreatePhysics(0.0, 0.0, onGround: true); + var current = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 80, 0.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop, + PreserveSprint = false + }; + + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(current, null, new Location(1.41, 80, 0.5), physics, world); + + Assert.True(decision.HoldForward); + Assert.False(decision.HoldSprint); + Assert.False(decision.HoldBack); + } + + [Fact] + public void ShouldReleaseForwardInAir_ReturnsTrue_ForParkourIntoTurn() + { + var physics = CreatePhysics(0.32, 0.0, onGround: false); + var current = new PathSegment + { + Start = new Location(120.5, 80, 110.5), + End = new Location(123.5, 80, 110.5), + MoveType = MoveType.Parkour, + ExitTransition = PathTransitionType.Turn, + PreserveSprint = false + }; + var next = new PathSegment + { + Start = new Location(123.5, 80, 110.5), + End = new Location(123.5, 80, 111.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop + }; + + bool release = TransitionBrakingPlanner.ShouldReleaseForwardInAir(current, next, new Location(123.18, 80.92, 110.5), physics); + + Assert.True(release); + } + + private static PlayerPhysics CreatePhysics(double deltaX, double deltaZ, bool onGround) + { + return new PlayerPhysics + { + Position = new Vec3d(0.0, 80.0, 0.0), + DeltaMovement = new Vec3d(deltaX, 0.0, deltaZ), + OnGround = onGround, + MovementSpeed = 0.1f, + Yaw = 270f + }; + } +} diff --git a/MinecraftClient.sln b/MinecraftClient.sln index ebdf1f09..19afc906 100644 --- a/MinecraftClient.sln +++ b/MinecraftClient.sln @@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MccMcpStdioHarness", "Debug EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MccMcpWebPlayground", "DebugTools\MccMcpWebPlayground\MccMcpWebPlayground.csproj", "{5F620CF6-BC7D-449A-B779-2D51985059C6}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MinecraftClient.Tests", "MinecraftClient.Tests\MinecraftClient.Tests.csproj", "{A6F319D6-4D0E-4D46-A31E-EF64E5F9F596}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -71,6 +73,18 @@ Global {5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|x64.Build.0 = Release|Any CPU {5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|x86.ActiveCfg = Release|Any CPU {5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|x86.Build.0 = Release|Any CPU + {A6F319D6-4D0E-4D46-A31E-EF64E5F9F596}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A6F319D6-4D0E-4D46-A31E-EF64E5F9F596}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A6F319D6-4D0E-4D46-A31E-EF64E5F9F596}.Debug|x64.ActiveCfg = Debug|Any CPU + {A6F319D6-4D0E-4D46-A31E-EF64E5F9F596}.Debug|x64.Build.0 = Debug|Any CPU + {A6F319D6-4D0E-4D46-A31E-EF64E5F9F596}.Debug|x86.ActiveCfg = Debug|Any CPU + {A6F319D6-4D0E-4D46-A31E-EF64E5F9F596}.Debug|x86.Build.0 = Debug|Any CPU + {A6F319D6-4D0E-4D46-A31E-EF64E5F9F596}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A6F319D6-4D0E-4D46-A31E-EF64E5F9F596}.Release|Any CPU.Build.0 = Release|Any CPU + {A6F319D6-4D0E-4D46-A31E-EF64E5F9F596}.Release|x64.ActiveCfg = Release|Any CPU + {A6F319D6-4D0E-4D46-A31E-EF64E5F9F596}.Release|x64.Build.0 = Release|Any CPU + {A6F319D6-4D0E-4D46-A31E-EF64E5F9F596}.Release|x86.ActiveCfg = Release|Any CPU + {A6F319D6-4D0E-4D46-A31E-EF64E5F9F596}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/MinecraftClient/Pathing/Execution/ActionTemplateFactory.cs b/MinecraftClient/Pathing/Execution/ActionTemplateFactory.cs index eff8aa42..2e20490c 100644 --- a/MinecraftClient/Pathing/Execution/ActionTemplateFactory.cs +++ b/MinecraftClient/Pathing/Execution/ActionTemplateFactory.cs @@ -9,17 +9,17 @@ namespace MinecraftClient.Pathing.Execution /// public static class ActionTemplateFactory { - public static IActionTemplate Create(PathSegment segment) + public static IActionTemplate Create(PathSegment segment, PathSegment? nextSegment) { return segment.MoveType switch { - MoveType.Traverse => new WalkTemplate(segment.Start, segment.End), - MoveType.Diagonal => new WalkTemplate(segment.Start, segment.End), - MoveType.Ascend => new AscendTemplate(segment.Start, segment.End), - MoveType.Descend => new DescendTemplate(segment.Start, segment.End), - MoveType.Fall => new FallTemplate(segment.Start, segment.End), - MoveType.Climb => new ClimbTemplate(segment.Start, segment.End), - MoveType.Parkour => new SprintJumpTemplate(segment.Start, segment.End), + MoveType.Traverse => new WalkTemplate(segment, nextSegment), + MoveType.Diagonal => new WalkTemplate(segment, nextSegment), + MoveType.Ascend => new AscendTemplate(segment, nextSegment), + MoveType.Descend => new DescendTemplate(segment, nextSegment), + MoveType.Fall => new FallTemplate(segment, nextSegment), + MoveType.Climb => new ClimbTemplate(segment, nextSegment), + MoveType.Parkour => new SprintJumpTemplate(segment, nextSegment), _ => throw new ArgumentException($"Unknown MoveType: {segment.MoveType}") }; } diff --git a/MinecraftClient/Pathing/Execution/IActionTemplate.cs b/MinecraftClient/Pathing/Execution/IActionTemplate.cs index dac2c44c..6e792780 100644 --- a/MinecraftClient/Pathing/Execution/IActionTemplate.cs +++ b/MinecraftClient/Pathing/Execution/IActionTemplate.cs @@ -20,6 +20,6 @@ namespace MinecraftClient.Pathing.Execution Location ExpectedStart { get; } Location ExpectedEnd { get; } - TemplateState Tick(Location currentPos, PlayerPhysics physics, MovementInput input); + TemplateState Tick(Location currentPos, PlayerPhysics physics, MovementInput input, World world); } } diff --git a/MinecraftClient/Pathing/Execution/PathExecutor.cs b/MinecraftClient/Pathing/Execution/PathExecutor.cs index 78270ee5..00d788e1 100644 --- a/MinecraftClient/Pathing/Execution/PathExecutor.cs +++ b/MinecraftClient/Pathing/Execution/PathExecutor.cs @@ -37,16 +37,20 @@ namespace MinecraftClient.Pathing.Execution AdvanceToNextSegment(); } - public PathExecutorState Tick(Location pos, PlayerPhysics physics, MovementInput input) + public PathExecutorState Tick(Location pos, PlayerPhysics physics, MovementInput input, World world) { if (_currentTemplate is null) + { + input.Reset(); return PathExecutorState.Complete; + } - var state = _currentTemplate.Tick(pos, physics, input); + var state = _currentTemplate.Tick(pos, physics, input, world); switch (state) { case TemplateState.Complete: + input.Reset(); _debugLog?.Invoke($"[PathExec] Segment {_currentIndex} complete " + $"({_segments[_currentIndex].MoveType}) at ({pos.X:F2},{pos.Y:F2},{pos.Z:F2})"); _currentIndex++; @@ -60,6 +64,7 @@ namespace MinecraftClient.Pathing.Execution return PathExecutorState.InProgress; case TemplateState.Failed: + input.Reset(); _debugLog?.Invoke($"[PathExec] Segment {_currentIndex} FAILED " + $"({_segments[_currentIndex].MoveType}) at ({pos.X:F2},{pos.Y:F2},{pos.Z:F2}), " + $"target was ({_currentTemplate.ExpectedEnd.X:F2},{_currentTemplate.ExpectedEnd.Y:F2},{_currentTemplate.ExpectedEnd.Z:F2})"); @@ -75,7 +80,8 @@ namespace MinecraftClient.Pathing.Execution if (_currentIndex < _segments.Count) { var seg = _segments[_currentIndex]; - _currentTemplate = ActionTemplateFactory.Create(seg); + PathSegment? next = _currentIndex + 1 < _segments.Count ? _segments[_currentIndex + 1] : null; + _currentTemplate = ActionTemplateFactory.Create(seg, next); _debugLog?.Invoke($"[PathExec] Starting segment {_currentIndex}/{_segments.Count}: {seg}"); } else diff --git a/MinecraftClient/Pathing/Execution/PathSegment.cs b/MinecraftClient/Pathing/Execution/PathSegment.cs index ec3f0a76..c39e88de 100644 --- a/MinecraftClient/Pathing/Execution/PathSegment.cs +++ b/MinecraftClient/Pathing/Execution/PathSegment.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System; using MinecraftClient.Mapping; using MinecraftClient.Pathing.Core; @@ -9,25 +9,13 @@ namespace MinecraftClient.Pathing.Execution public required Location Start { get; init; } public required Location End { get; init; } public required MoveType MoveType { get; init; } + public PathTransitionType ExitTransition { get; init; } = PathTransitionType.FinalStop; + public bool PreserveSprint { get; init; } - public static List FromPath(IReadOnlyList nodes) - { - var segments = new List(nodes.Count - 1); - for (int i = 1; i < nodes.Count; i++) - { - var prev = nodes[i - 1]; - var curr = nodes[i]; - segments.Add(new PathSegment - { - Start = new Location(prev.X + 0.5, prev.Y, prev.Z + 0.5), - End = new Location(curr.X + 0.5, curr.Y, curr.Z + 0.5), - MoveType = curr.MoveUsed - }); - } - return segments; - } + public int HeadingX => Math.Sign(End.X - Start.X); + public int HeadingZ => Math.Sign(End.Z - Start.Z); public override string ToString() => - $"{MoveType}: ({Start.X:F1},{Start.Y:F1},{Start.Z:F1})->({End.X:F1},{End.Y:F1},{End.Z:F1})"; + $"{MoveType}: ({Start.X:F1},{Start.Y:F1},{Start.Z:F1})->({End.X:F1},{End.Y:F1},{End.Z:F1}), transition={ExitTransition}, preserveSprint={PreserveSprint}"; } } diff --git a/MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs b/MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs new file mode 100644 index 00000000..36db785d --- /dev/null +++ b/MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Execution +{ + public static class PathSegmentBuilder + { + public static List FromPath(IReadOnlyList nodes) + { + 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 + }; + + PathTransitionType exitTransition = Classify(current, next); + segments.Add(new PathSegment + { + Start = current.Start, + End = current.End, + MoveType = current.MoveType, + ExitTransition = exitTransition, + PreserveSprint = exitTransition is PathTransitionType.ContinueStraight or PathTransitionType.PrepareJump + }); + } + return segments; + } + + private static PathTransitionType Classify(PathSegment current, PathSegment? next) + { + if (next is null) + return PathTransitionType.FinalStop; + + if (next.MoveType is MoveType.Parkour or MoveType.Ascend) + return PathTransitionType.PrepareJump; + + if (current.MoveType is MoveType.Parkour or MoveType.Descend or MoveType.Fall) + return PathTransitionType.LandingRecovery; + + if (current.HeadingX == next.HeadingX && current.HeadingZ == next.HeadingZ) + return PathTransitionType.ContinueStraight; + + return PathTransitionType.Turn; + } + } +} diff --git a/MinecraftClient/Pathing/Execution/PathSegmentManager.cs b/MinecraftClient/Pathing/Execution/PathSegmentManager.cs index 1582dd4a..d3911ae9 100644 --- a/MinecraftClient/Pathing/Execution/PathSegmentManager.cs +++ b/MinecraftClient/Pathing/Execution/PathSegmentManager.cs @@ -35,7 +35,7 @@ namespace MinecraftClient.Pathing.Execution { _goal = goal; _replanCount = 0; - var segments = PathSegment.FromPath(result.Path); + var segments = PathSegmentBuilder.FromPath(result.Path); _executor = new PathExecutor(segments, _debugLog); _infoLog?.Invoke($"[PathMgr] Navigation started: {segments.Count} segments"); } @@ -45,7 +45,7 @@ namespace MinecraftClient.Pathing.Execution if (_executor is null) return; - var state = _executor.Tick(pos, physics, input); + var state = _executor.Tick(pos, physics, input, world); switch (state) { @@ -113,7 +113,7 @@ namespace MinecraftClient.Pathing.Execution return; } - var segments = PathSegment.FromPath(result.Path); + var segments = PathSegmentBuilder.FromPath(result.Path); _executor = new PathExecutor(segments, _debugLog); _infoLog?.Invoke($"[PathMgr] Replanned: {segments.Count} segments (replan #{_replanCount})"); } diff --git a/MinecraftClient/Pathing/Execution/PathTransitionType.cs b/MinecraftClient/Pathing/Execution/PathTransitionType.cs new file mode 100644 index 00000000..f099c0ac --- /dev/null +++ b/MinecraftClient/Pathing/Execution/PathTransitionType.cs @@ -0,0 +1,11 @@ +namespace MinecraftClient.Pathing.Execution +{ + public enum PathTransitionType + { + FinalStop, + ContinueStraight, + Turn, + PrepareJump, + LandingRecovery + } +} diff --git a/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs index 3ad3c41e..536fae7b 100644 --- a/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs @@ -13,18 +13,22 @@ namespace MinecraftClient.Pathing.Execution.Templates public Location ExpectedStart { get; } public Location ExpectedEnd { get; } + private readonly PathSegment _segment; + private readonly PathSegment? _nextSegment; private int _tickCount; private Location _lastPos; private int _stuckTicks; - public AscendTemplate(Location start, Location end) + public AscendTemplate(PathSegment segment, PathSegment? nextSegment) { - ExpectedStart = start; - ExpectedEnd = end; - _lastPos = start; + _segment = segment; + _nextSegment = nextSegment; + ExpectedStart = segment.Start; + ExpectedEnd = segment.End; + _lastPos = segment.Start; } - public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input, World world) { _tickCount++; @@ -43,8 +47,26 @@ namespace MinecraftClient.Pathing.Execution.Templates if (physics.OnGround && dy > 0.1) input.Jump = true; - if (horizDistSq < 0.25 && Math.Abs(dy) < 0.8) + if (physics.OnGround && Math.Abs(dy) < 0.15) + { + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world); + TemplateHelper.ApplyDecision(input, decision); + if (decision.HoldBack) + TemplateHelper.FaceSegmentHeading(physics, _segment); + + if (_segment.ExitTransition == PathTransitionType.ContinueStraight && horizDistSq < 0.25) + return TemplateState.Complete; + + if (_segment.ExitTransition != PathTransitionType.ContinueStraight + && TemplateHelper.IsSettledAtEnd(pos, ExpectedEnd, physics, horizThresholdSq: 0.0025)) + { + return TemplateState.Complete; + } + } + else if (horizDistSq < 0.25 && Math.Abs(dy) < 0.8) + { return TemplateState.Complete; + } double movedSq = TemplateHelper.HorizontalDistanceSq(pos, _lastPos); double movedY = Math.Abs(pos.Y - _lastPos.Y); diff --git a/MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs index 0ffea56d..4e6c3991 100644 --- a/MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs @@ -17,14 +17,14 @@ namespace MinecraftClient.Pathing.Execution.Templates private readonly bool _goingUp; private int _tickCount; - public ClimbTemplate(Location start, Location end) + public ClimbTemplate(PathSegment segment, PathSegment? nextSegment) { - ExpectedStart = start; - ExpectedEnd = end; - _goingUp = end.Y > start.Y; + ExpectedStart = segment.Start; + ExpectedEnd = segment.End; + _goingUp = segment.End.Y > segment.Start.Y; } - public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input, World world) { _tickCount++; diff --git a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs index 9cec3f78..aa7edfe4 100644 --- a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs @@ -15,20 +15,24 @@ namespace MinecraftClient.Pathing.Execution.Templates public Location ExpectedStart { get; } public Location ExpectedEnd { get; } + private readonly PathSegment _segment; + private readonly PathSegment? _nextSegment; private int _tickCount; private bool _hasFallen; private readonly bool _needsSprint; - public DescendTemplate(Location start, Location end) + public DescendTemplate(PathSegment segment, PathSegment? nextSegment) { - ExpectedStart = start; - ExpectedEnd = end; - double hdx = end.X - start.X; - double hdz = end.Z - start.Z; + _segment = segment; + _nextSegment = nextSegment; + ExpectedStart = segment.Start; + ExpectedEnd = segment.End; + double hdx = segment.End.X - segment.Start.X; + double hdz = segment.End.Z - segment.Start.Z; _needsSprint = (hdx * hdx + hdz * hdz) > 2.25; } - public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input, World world) { _tickCount++; @@ -40,14 +44,6 @@ namespace MinecraftClient.Pathing.Execution.Templates if (!physics.OnGround) _hasFallen = true; - // Completion: landed on ground near destination - if (_hasFallen && physics.OnGround && horizDistSq < 0.5 && Math.Abs(dy) < 0.8) - return TemplateState.Complete; - - // Completion: already at destination without falling (e.g., single step down) - if (horizDistSq < 0.25 && Math.Abs(dy) < 0.5 && physics.OnGround) - return TemplateState.Complete; - // Completion: landed in water near destination if (_hasFallen && physics.InWater && horizDistSq < 0.5 && Math.Abs(dy) < 2.0) return TemplateState.Complete; @@ -63,7 +59,28 @@ namespace MinecraftClient.Pathing.Execution.Templates float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz); physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch); - if (physics.OnClimbable) + if (physics.OnGround && Math.Abs(dy) < (_hasFallen ? 0.8 : 0.5)) + { + if (horizDistSq > 0.01) + physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); + + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world); + TemplateHelper.ApplyDecision(input, decision); + if (decision.HoldBack) + TemplateHelper.FaceSegmentHeading(physics, _segment); + + if (_segment.ExitTransition == PathTransitionType.ContinueStraight) + { + double completionThreshold = _hasFallen ? 0.5 : 0.25; + if (horizDistSq < completionThreshold) + return TemplateState.Complete; + } + else if (TemplateHelper.IsSettledAtEnd(pos, ExpectedEnd, physics, horizThresholdSq: 0.0025)) + { + return TemplateState.Complete; + } + } + else if (physics.OnClimbable) { if (horizDistSq > 0.25) { diff --git a/MinecraftClient/Pathing/Execution/Templates/FallTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/FallTemplate.cs index 7a4131e6..0380ea06 100644 --- a/MinecraftClient/Pathing/Execution/Templates/FallTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/FallTemplate.cs @@ -16,27 +16,30 @@ namespace MinecraftClient.Pathing.Execution.Templates private int _tickCount; private bool _hasFallen; - public FallTemplate(Location start, Location end) + public FallTemplate(PathSegment segment, PathSegment? nextSegment) { - ExpectedStart = start; - ExpectedEnd = end; + ExpectedStart = segment.Start; + ExpectedEnd = segment.End; } - public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input, World world) { _tickCount++; + double dx = ExpectedEnd.X - pos.X; + double dz = ExpectedEnd.Z - pos.Z; double dy = pos.Y - ExpectedEnd.Y; + double horizDistSq = dx * dx + dz * dz; if (!physics.OnGround) _hasFallen = true; - // Solid ground landing - if (_hasFallen && physics.OnGround && Math.Abs(dy) < 1.0) + // Solid ground landing near the target XZ + if (_hasFallen && physics.OnGround && Math.Abs(dy) < 1.0 && horizDistSq < 1.0) return TemplateState.Complete; - // Water landing - if (_hasFallen && physics.InWater && Math.Abs(dy) < 2.0) + // Water landing near the target XZ + if (_hasFallen && physics.InWater && Math.Abs(dy) < 2.0 && horizDistSq < 1.5) return TemplateState.Complete; if (_tickCount > 200) diff --git a/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs index eed7b472..9ec8aa8b 100644 --- a/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs @@ -5,10 +5,17 @@ using MinecraftClient.Physics; namespace MinecraftClient.Pathing.Execution.Templates { /// - /// Sprint-jump across a gap. Uses a phase-based state machine: - /// Approach -> jump when ready -> Airborne -> Landing check. - /// For long jumps (>= 3.5 blocks), delays the jump until the player - /// has moved toward the edge of the starting block for maximum distance. + /// Jump across a gap. Uses a phase-based state machine: + /// Approach -> Jump -> Airborne -> Landing. + /// + /// All parkour jumps use sprint-jumping (vanilla optimal horizontal distance). + /// The key to landing on small platforms is releasing forward/sprint input mid-air + /// once the player is close to or past the target, letting drag decelerate them + /// onto the block. + /// + /// During Approach, the template waits for the yaw to be within 5 degrees of + /// the target direction before jumping. For medium/long jumps, it also builds + /// momentum by sprinting toward the block edge. /// public sealed class SprintJumpTemplate : IActionTemplate { @@ -17,22 +24,27 @@ namespace MinecraftClient.Pathing.Execution.Templates public Location ExpectedStart { get; } public Location ExpectedEnd { get; } + private readonly PathSegment _segment; + private readonly PathSegment? _nextSegment; private readonly double _horizDist; - private readonly bool _isDiagonal; private int _tickCount; private Phase _phase = Phase.Approach; + private bool _leftGround; - public SprintJumpTemplate(Location start, Location end) + private const float YawToleranceDeg = 5f; + + public SprintJumpTemplate(PathSegment segment, PathSegment? nextSegment) { - ExpectedStart = start; - ExpectedEnd = end; - double dx = end.X - start.X; - double dz = end.Z - start.Z; + _segment = segment; + _nextSegment = nextSegment; + ExpectedStart = segment.Start; + ExpectedEnd = segment.End; + double dx = segment.End.X - segment.Start.X; + double dz = segment.End.Z - segment.Start.Z; _horizDist = Math.Sqrt(dx * dx + dz * dz); - _isDiagonal = Math.Abs(dx) > 0.5 && Math.Abs(dz) > 0.5; } - public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input, World world) { _tickCount++; @@ -45,52 +57,92 @@ namespace MinecraftClient.Pathing.Execution.Templates float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz); physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch); - input.Forward = true; - input.Sprint = true; switch (_phase) { case Phase.Approach: + input.Forward = true; + input.Sprint = true; + if (physics.OnGround) { double fromStartSq = TemplateHelper.HorizontalDistanceSq(pos, ExpectedStart); + float yawDelta = YawDifference(physics.Yaw, targetYaw); - // For long jumps, delay the jump until the player has sprinted - // toward the block edge. Baritone waits until playerFeet is in - // the next block (~0.5 blocks from center) for dist >= 4. - // For medium jumps (dist 3), wait 0.35 blocks (Baritone: 0.7). - // For short diagonal jumps (<= 3 blocks), jump immediately - // to avoid overshooting the small starting platform. + // Build momentum before jumping. Sprint speed is ~5.6 m/s + // (0.28 blocks/tick). More run-up = more airtime distance. + // Standing sprint jump (0t): ~3.6 blocks horizontal + // 2-tick sprint (0.56m): ~4.3 blocks horizontal + // 4-tick sprint (1.1m): ~5.0 blocks horizontal double minApproachSq; - if (_horizDist >= 3.5) - minApproachSq = 0.25; // 0.5 blocks - else if (_horizDist >= 2.5 && !_isDiagonal) - minApproachSq = 0.12; // ~0.35 blocks + if (_horizDist >= 5.0) + 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) + minApproachSq = 0.09; // 0.3 blocks - 1-2 ticks of sprint else minApproachSq = 0.0; - if (fromStartSq >= minApproachSq) + bool yawAligned = yawDelta < YawToleranceDeg; + bool posReady = fromStartSq >= minApproachSq; + + if (yawAligned && posReady) { input.Jump = true; _phase = Phase.Airborne; } } - if (_tickCount > 30) + if (_tickCount > 40) return TemplateState.Failed; break; case Phase.Airborne: + { if (!physics.OnGround) - break; - _phase = Phase.Landing; - goto case Phase.Landing; + _leftGround = true; + + bool pastTarget = IsPastTarget(pos); + bool releaseInAir = TransitionBrakingPlanner.ShouldReleaseForwardInAir(_segment, _nextSegment, pos, physics); + + if (releaseInAir || pastTarget) + { + input.Forward = false; + input.Sprint = false; + } + else + { + input.Forward = true; + input.Sprint = true; + } + + if (_leftGround && physics.OnGround) + { + _phase = Phase.Landing; + goto case Phase.Landing; + } + break; + } case Phase.Landing: - double horizTolerance = _horizDist >= 3.5 ? 3.0 : 2.0; + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world); + TemplateHelper.ApplyDecision(input, decision); + if (decision.HoldBack) + TemplateHelper.FaceSegmentHeading(physics, _segment); + + double horizToleranceLinear = _horizDist >= 3.5 ? 1.5 : 1.0; + double horizToleranceSq = horizToleranceLinear * horizToleranceLinear; double vertTolerance = Math.Abs(ExpectedEnd.Y - ExpectedStart.Y) > 0.5 ? 1.5 : 1.0; - if (horizDistSq < horizTolerance && Math.Abs(dy) < vertTolerance) + if (_segment.ExitTransition == PathTransitionType.ContinueStraight + && horizDistSq < horizToleranceSq && Math.Abs(dy) < vertTolerance) return TemplateState.Complete; - return TemplateState.Failed; + + if (_segment.ExitTransition != PathTransitionType.ContinueStraight + && TemplateHelper.IsSettledAtEnd(pos, ExpectedEnd, physics, horizThresholdSq: 0.0025)) + { + return TemplateState.Complete; + } + break; } if (pos.Y < ExpectedEnd.Y - 4.0) @@ -101,5 +153,28 @@ namespace MinecraftClient.Pathing.Execution.Templates return TemplateState.InProgress; } + + private bool IsPastTarget(Location pos) + { + double dirX = ExpectedEnd.X - ExpectedStart.X; + double dirZ = ExpectedEnd.Z - ExpectedStart.Z; + double len = Math.Sqrt(dirX * dirX + dirZ * dirZ); + if (len < 0.001) return false; + dirX /= len; + dirZ /= len; + + double relX = pos.X - ExpectedEnd.X; + double relZ = pos.Z - ExpectedEnd.Z; + double dot = relX * dirX + relZ * dirZ; + return dot > 0.0; + } + + private static float YawDifference(float current, float target) + { + float delta = target - current; + while (delta > 180f) delta -= 360f; + while (delta < -180f) delta += 360f; + return Math.Abs(delta); + } } } diff --git a/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs b/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs index f724906c..3a07c910 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.Physics; namespace MinecraftClient.Pathing.Execution.Templates { @@ -75,5 +76,28 @@ namespace MinecraftClient.Pathing.Execution.Templates double dy = target.Y - pos.Y; return dx * dx + dz * dz < horizThresholdSq && Math.Abs(dy) < vertThreshold; } + + internal static void FaceSegmentHeading(PlayerPhysics physics, PathSegment segment) + { + float headingYaw = CalculateYaw(segment.HeadingX, segment.HeadingZ); + physics.Yaw = SmoothYaw(physics.Yaw, headingYaw); + } + + internal static void ApplyDecision(MovementInput input, TransitionBrakingDecision decision) + { + input.Forward = decision.HoldForward; + input.Sprint = decision.HoldSprint; + input.Back = decision.HoldBack; + } + + internal static bool IsSettledAtEnd(Location pos, Location target, PlayerPhysics physics, + double horizThresholdSq = 0.0025, double speedThresholdSq = 0.0016) + { + 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; + } } } diff --git a/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs index 0cdbf96e..79c99e0b 100644 --- a/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs @@ -13,18 +13,22 @@ namespace MinecraftClient.Pathing.Execution.Templates public Location ExpectedStart { get; } public Location ExpectedEnd { get; } + private readonly PathSegment _segment; + private readonly PathSegment? _nextSegment; private int _tickCount; private Location _lastPos; private int _stuckTicks; - public WalkTemplate(Location start, Location end) + public WalkTemplate(PathSegment segment, PathSegment? nextSegment) { - ExpectedStart = start; - ExpectedEnd = end; - _lastPos = start; + _segment = segment; + _nextSegment = nextSegment; + ExpectedStart = segment.Start; + ExpectedEnd = segment.End; + _lastPos = segment.Start; } - public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input, World world) { _tickCount++; @@ -35,17 +39,24 @@ namespace MinecraftClient.Pathing.Execution.Templates float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz); physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw); physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch); - input.Forward = true; - input.Sprint = true; - if (TemplateHelper.IsNear(pos, ExpectedEnd, horizThresholdSq: 0.20)) + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world); + TemplateHelper.ApplyDecision(input, decision); + if (decision.HoldBack) + TemplateHelper.FaceSegmentHeading(physics, _segment); + + if (_segment.ExitTransition == PathTransitionType.ContinueStraight && TemplateHelper.IsNear(pos, ExpectedEnd, horizThresholdSq: 0.09)) + return TemplateState.Complete; + + if (_segment.ExitTransition != PathTransitionType.ContinueStraight && TemplateHelper.IsSettledAtEnd(pos, ExpectedEnd, physics)) return TemplateState.Complete; double movedSq = TemplateHelper.HorizontalDistanceSq(pos, _lastPos); _stuckTicks = movedSq < 0.0005 ? _stuckTicks + 1 : 0; _lastPos = pos; - if (_stuckTicks > 40 || _tickCount > 100) + int maxTicks = _segment.ExitTransition == PathTransitionType.ContinueStraight ? 100 : 140; + if (_stuckTicks > 40 || _tickCount > maxTicks) return TemplateState.Failed; return TemplateState.InProgress; diff --git a/MinecraftClient/Pathing/Execution/TransitionBrakingDecision.cs b/MinecraftClient/Pathing/Execution/TransitionBrakingDecision.cs new file mode 100644 index 00000000..a51e8b6a --- /dev/null +++ b/MinecraftClient/Pathing/Execution/TransitionBrakingDecision.cs @@ -0,0 +1,14 @@ +namespace MinecraftClient.Pathing.Execution +{ + public readonly record struct TransitionBrakingDecision(bool HoldForward, bool HoldSprint, bool HoldBack) + { + public static TransitionBrakingDecision CarryMomentum(bool preserveSprint) => + new(true, preserveSprint, false); + + public static TransitionBrakingDecision Coast => + new(false, false, false); + + public static TransitionBrakingDecision Brake => + new(false, false, true); + } +} diff --git a/MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs b/MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs new file mode 100644 index 00000000..1899d389 --- /dev/null +++ b/MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs @@ -0,0 +1,107 @@ +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution +{ + public static class TransitionBrakingPlanner + { + private const double GroundSpeedThreshold = 0.025; + private const int MaxSimulationTicks = 14; + private const double FinalStopLead = 0.06; + private const double FinalBrakeLead = 0.04; + private const double TurnBrakeLead = 0.10; + private const double AirReleaseLead = 0.14; + + 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); + + if (current.ExitTransition == PathTransitionType.FinalStop) + { + if (remaining < 0.0) + return TransitionBrakingDecision.Brake; + + if (forwardSpeed > GroundSpeedThreshold && remaining <= hardBrakeDistance + FinalBrakeLead) + return TransitionBrakingDecision.Brake; + + if (forwardSpeed <= GroundSpeedThreshold && remaining > 0.0) + return TransitionBrakingDecision.CarryMomentum(preserveSprint: false); + } + + if (current.ExitTransition == PathTransitionType.Turn && remaining <= hardBrakeDistance + TurnBrakeLead) + { + return TransitionBrakingDecision.Brake; + } + + if (remaining <= coastStopDistance + FinalStopLead) + return TransitionBrakingDecision.Coast; + + return TransitionBrakingDecision.CarryMomentum(current.PreserveSprint); + } + + public static bool ShouldReleaseForwardInAir(PathSegment current, PathSegment? next, Location pos, PlayerPhysics physics) + { + if (current.ExitTransition is not (PathTransitionType.FinalStop or PathTransitionType.Turn or PathTransitionType.LandingRecovery)) + return false; + + double remaining = RemainingDistanceAlongSegment(current, pos); + double forwardSpeed = Math.Max(0.0, ProjectHorizontalSpeedAlongHeading(physics, current.HeadingX, current.HeadingZ)); + + return remaining <= forwardSpeed + AirReleaseLead; + } + + public static double EstimateGroundStopDistance(PlayerPhysics physics, World world, int headingX, int headingZ, bool applyBackBrake) + { + if (!physics.OnGround) + return 0.0; + + double forwardSpeed = Math.Max(0.0, ProjectHorizontalSpeedAlongHeading(physics, headingX, headingZ)); + if (forwardSpeed <= GroundSpeedThreshold) + return 0.0; + + float blockFriction = PlayerPhysics.GetMaterialFriction( + world.GetBlock(new Location(physics.Position.X, physics.Position.Y - 0.5000010, physics.Position.Z)).Type); + double drag = blockFriction * PhysicsConsts.FrictionMultiplier; + double acceleration = physics.MovementSpeed + * (PhysicsConsts.GroundAccelerationFactor / (drag * drag * drag)) + * PhysicsConsts.InputFriction; + + if (applyBackBrake) + acceleration *= 0.98; + + double distance = 0.0; + double speed = forwardSpeed; + for (int tick = 0; tick < MaxSimulationTicks; tick++) + { + distance += speed; + speed = applyBackBrake + ? Math.Max(0.0, (speed - acceleration) * drag) + : speed * drag; + + if (speed <= GroundSpeedThreshold) + break; + } + + return distance; + } + + private static double RemainingDistanceAlongSegment(PathSegment current, Location pos) + { + double dx = current.End.X - pos.X; + double dz = current.End.Z - pos.Z; + return dx * current.HeadingX + dz * current.HeadingZ; + } + + private static double ProjectHorizontalSpeedAlongHeading(PlayerPhysics physics, int headingX, int headingZ) + { + return physics.DeltaMovement.X * headingX + physics.DeltaMovement.Z * headingZ; + } + } +}