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;
+ }
+ }
+}