feat: converge grounded path segment completion

This commit is contained in:
BruceChen 2026-04-12 21:33:24 +08:00
parent 3b4e552d70
commit 6b449cc72a
12 changed files with 489 additions and 43 deletions

View file

@ -0,0 +1,99 @@
using System;
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 ClimbFallTemplateTests
{
[Fact]
public void ClimbTemplate_AscendsLadderColumn_CompletesOverTarget()
{
World world = FlatWorldTestBuilder.CreateStoneFloor(min: -2, max: 2);
BuildLadder(world, x: 0, z: 0, bottomY: 80, topY: 84);
var segment = new PathSegment
{
Start = new Location(0.5, 80, 0.5),
End = new Location(0.5, 84, 0.5),
MoveType = MoveType.Climb,
ExitTransition = PathTransitionType.FinalStop
};
var template = new ClimbTemplate(segment, null);
var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 0f);
physics.OnClimbable = true;
TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 220, out Location finalPos);
Assert.Equal(TemplateState.Complete, state);
AssertNearTargetBlock(finalPos, segment.End);
}
[Fact]
public void ClimbTemplate_DescendsLadderColumn_CompletesOverTarget()
{
World world = FlatWorldTestBuilder.CreateStoneFloor(min: -2, max: 2);
BuildLadder(world, x: 0, z: 0, bottomY: 80, topY: 84);
var segment = new PathSegment
{
Start = new Location(0.5, 84, 0.5),
End = new Location(0.5, 80, 0.5),
MoveType = MoveType.Climb,
ExitTransition = PathTransitionType.FinalStop
};
var template = new ClimbTemplate(segment, null);
var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 180f);
physics.OnClimbable = true;
TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 220, out Location finalPos);
Assert.Equal(TemplateState.Complete, state);
AssertNearTargetBlock(finalPos, segment.End);
}
[Fact]
public void FallTemplate_DropsStraightDown_CompletesOnFloor()
{
World world = FlatWorldTestBuilder.CreateStoneFloor(min: 4, max: 8);
var segment = new PathSegment
{
Start = new Location(5.5, 85, 5.5),
End = new Location(5.5, 80, 5.5),
MoveType = MoveType.Fall,
ExitTransition = PathTransitionType.FinalStop
};
var template = new FallTemplate(segment, null);
var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 90f);
physics.OnGround = false;
physics.DeltaMovement = new Vec3d(0, -0.15, 0);
TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 260, out Location finalPos);
Assert.Equal(TemplateState.Complete, state);
AssertNearTargetBlock(finalPos, segment.End);
}
private static void AssertNearTargetBlock(Location actual, Location target)
{
Assert.True(Math.Abs(actual.Y - target.Y) < 0.6, $"Expected final Y near {target.Y:F2}, got {actual.Y:F2}");
Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(actual, target),
$"Expected the final footprint to stay within {target}, got {actual}");
}
private static void BuildLadder(World world, int x, int z, int bottomY, int topY)
{
for (int y = bottomY; y <= topY; y++)
{
FlatWorldTestBuilder.SetClimbable(world, x, y, z);
}
}
}

View file

@ -1,6 +1,9 @@
using System;
using System.Collections.Generic;
using System.Threading;
using MinecraftClient.Mapping;
using MinecraftClient.Mapping.BlockPalettes;
using MinecraftClient.Physics;
namespace MinecraftClient.Tests.Pathing.Execution;
@ -8,6 +11,7 @@ internal static class FlatWorldTestBuilder
{
private static readonly Lock InitLock = new();
private static bool _defaultsLoaded;
private static readonly Dictionary<Material, ushort> MaterialIds = new();
public static World CreateStoneFloor(int floorY = 79, int min = -32, int max = 32)
{
@ -30,13 +34,56 @@ internal static class FlatWorldTestBuilder
{
for (int z = min; z <= max; z++)
{
world.SetBlock(new Location(x, floorY, z), new Block(1));
SetSolid(world, x, floorY, z);
}
}
return world;
}
public static void SetSolid(World world, int x, int y, int z)
{
SetMaterial(world, x, y, z, Material.Stone);
}
public static void FillSolid(World world, int x1, int y1, int z1, int x2, int y2, int z2)
{
for (int x = Math.Min(x1, x2); x <= Math.Max(x1, x2); x++)
{
for (int y = Math.Min(y1, y2); y <= Math.Max(y1, y2); y++)
{
for (int z = Math.Min(z1, z2); z <= Math.Max(z1, z2); z++)
{
SetSolid(world, x, y, z);
}
}
}
}
public static void ClearBox(World world, int x1, int y1, int z1, int x2, int y2, int z2)
{
for (int x = Math.Min(x1, x2); x <= Math.Max(x1, x2); x++)
{
for (int y = Math.Min(y1, y2); y <= Math.Max(y1, y2); y++)
{
for (int z = Math.Min(z1, z2); z <= Math.Max(z1, z2); z++)
{
world.SetBlock(new Location(x, y, z), Block.Air);
}
}
}
}
public static void SetMaterial(World world, int x, int y, int z, Material material)
{
world.SetBlock(new Location(x, y, z), new Block(ResolveMaterialId(material)));
}
public static void SetClimbable(World world, int x, int y, int z)
{
SetMaterial(world, x, y, z, Material.Ladder);
}
private static void EnsureDefaultDimensionsLoaded()
{
lock (InitLock)
@ -44,8 +91,31 @@ internal static class FlatWorldTestBuilder
if (_defaultsLoaded)
return;
Block.Palette = new Palette1219();
World.LoadDefaultDimensions1206Plus();
BlockShapes.Initialize();
_defaultsLoaded = true;
}
}
private static ushort ResolveMaterialId(Material material)
{
lock (InitLock)
{
if (MaterialIds.TryGetValue(material, out ushort id))
return id;
for (int candidate = 0; candidate <= ushort.MaxValue; candidate++)
{
if (Block.Palette.FromId(candidate) == material)
{
ushort resolved = (ushort)candidate;
MaterialIds[material] = resolved;
return resolved;
}
}
throw new InvalidOperationException($"Could not resolve a block id for material {material}");
}
}
}

View file

@ -0,0 +1,84 @@
using MinecraftClient.Mapping;
using MinecraftClient.Pathing.Core;
using MinecraftClient.Pathing.Execution;
using MinecraftClient.Pathing.Execution.Templates;
using Xunit;
namespace MinecraftClient.Tests.Pathing.Execution;
public sealed class GroundedTemplateConvergenceTests
{
[Fact]
public void WalkTemplate_FinalStop_Completes_WhenFootprintStaysInsideTargetBlock()
{
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
};
var template = new WalkTemplate(segment, null);
var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 270f);
TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 160, out Location finalPos);
Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}");
Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End));
}
[Fact]
public void WalkTemplate_PrepareJump_CompletesWithoutSettlingOnRunUpBlock()
{
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.PrepareJump,
PreserveSprint = true
};
var next = new PathSegment
{
Start = new Location(1.5, 80, 0.5),
End = new Location(3.5, 80, 0.5),
MoveType = MoveType.Parkour,
ExitTransition = PathTransitionType.FinalStop
};
var template = new WalkTemplate(current, next);
var physics = TemplateSimulationRunner.CreateGroundedPhysics(current.Start, yaw: 270f);
TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 60, out _);
Assert.Equal(TemplateState.Complete, state);
Assert.True(physics.DeltaMovement.X > 0.02);
}
[Fact]
public void DescendTemplate_LandingRecovery_CompletesOnLandingBlock()
{
World world = FlatWorldTestBuilder.CreateStoneFloor();
FlatWorldTestBuilder.ClearBox(world, 1, 79, 0, 1, 79, 0);
FlatWorldTestBuilder.SetSolid(world, 1, 78, 0);
var segment = new PathSegment
{
Start = new Location(0.5, 80, 0.5),
End = new Location(1.5, 79, 0.5),
MoveType = MoveType.Descend,
ExitTransition = PathTransitionType.LandingRecovery
};
var template = new DescendTemplate(segment, null);
var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 270f);
TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 240, out Location finalPos);
Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}");
Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End));
}
}

View file

@ -24,7 +24,8 @@ public sealed class PathExecutorCompletionTests
var physics = new PlayerPhysics
{
Yaw = 270f,
Pitch = 0f
Pitch = 0f,
OnGround = true
};
var input = new MovementInput();
var pos = new Location(1.48, 80, 0.5);

View file

@ -0,0 +1,47 @@
using MinecraftClient.Mapping;
using MinecraftClient.Pathing.Execution.Templates;
using MinecraftClient.Physics;
using Xunit;
namespace MinecraftClient.Tests.Pathing.Execution;
public sealed class TemplateFootingTests
{
[Fact]
public void IsFootprintInsideTargetBlock_ReturnsTrue_WhenPlayerIsNearEdgeButStillInside()
{
bool inside = TemplateFootingHelper.IsFootprintInsideTargetBlock(
new Location(10.69, 80.0, 4.50),
new Location(10.50, 80.0, 4.50));
Assert.True(inside);
}
[Fact]
public void IsFootprintInsideTargetBlock_ReturnsFalse_WhenPlayerCrossesBlockEdge()
{
bool inside = TemplateFootingHelper.IsFootprintInsideTargetBlock(
new Location(10.81, 80.0, 4.50),
new Location(10.50, 80.0, 4.50));
Assert.False(inside);
}
[Fact]
public void WillLeaveTargetBlockNextTick_ReturnsTrue_WhenVelocityWouldCarryPastEdge()
{
var physics = new PlayerPhysics
{
Position = new Vec3d(10.67, 80.0, 4.50),
DeltaMovement = new Vec3d(0.060, 0.0, 0.0),
OnGround = true
};
bool exitsNextTick = TemplateFootingHelper.WillLeaveTargetBlockNextTick(
new Location(10.67, 80.0, 4.50),
physics,
new Location(10.50, 80.0, 4.50));
Assert.True(exitsNextTick);
}
}

View file

@ -0,0 +1,42 @@
using MinecraftClient.Mapping;
using MinecraftClient.Pathing.Execution;
using MinecraftClient.Physics;
namespace MinecraftClient.Tests.Pathing.Execution;
internal static class TemplateSimulationRunner
{
internal static PlayerPhysics CreateGroundedPhysics(Location start, float yaw)
{
return new PlayerPhysics
{
Position = new Vec3d(start.X, start.Y, start.Z),
DeltaMovement = Vec3d.Zero,
OnGround = true,
MovementSpeed = 0.1f,
Yaw = yaw,
Pitch = 0f
};
}
internal static TemplateState Run(IActionTemplate template, PlayerPhysics physics, World world, int maxTicks, out Location finalPos)
{
var input = new MovementInput();
TemplateState state = TemplateState.InProgress;
for (int tick = 0; tick < maxTicks; tick++)
{
input.Reset();
Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
state = template.Tick(pos, physics, input, world);
if (state != TemplateState.InProgress)
break;
physics.ApplyInput(input);
physics.Tick(world);
}
finalPos = new Location(physics.Position.X, physics.Position.Y, physics.Position.Z);
return state;
}
}

View file

@ -47,25 +47,11 @@ namespace MinecraftClient.Pathing.Execution.Templates
if (physics.OnGround && dy > 0.1)
input.Jump = true;
if (physics.OnGround && Math.Abs(dy) < 0.15)
if (physics.OnGround && Math.Abs(dy) < 0.2)
{
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)
GroundedSegmentController.Apply(_segment, _nextSegment, pos, physics, input, world);
if (GroundedSegmentController.ShouldComplete(_segment, pos, physics))
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);

View file

@ -59,26 +59,14 @@ namespace MinecraftClient.Pathing.Execution.Templates
float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz);
physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch);
if (physics.OnGround && Math.Abs(dy) < (_hasFallen ? 0.8 : 0.5))
if (physics.OnGround && Math.Abs(dy) < (_hasFallen ? 1.0 : 0.6))
{
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))
{
GroundedSegmentController.Apply(_segment, _nextSegment, pos, physics, input, world);
if (GroundedSegmentController.ShouldComplete(_segment, pos, physics))
return TemplateState.Complete;
}
}
else if (physics.OnClimbable)
{

View file

@ -0,0 +1,27 @@
using MinecraftClient.Mapping;
using MinecraftClient.Physics;
namespace MinecraftClient.Pathing.Execution.Templates
{
internal static class GroundedSegmentController
{
internal static void Apply(PathSegment segment, PathSegment? nextSegment, Location pos, PlayerPhysics physics, MovementInput input, World world)
{
TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(segment, nextSegment, pos, physics, world);
TemplateHelper.ApplyDecision(input, decision);
if (decision.HoldBack)
TemplateHelper.FaceSegmentHeading(physics, segment);
}
internal static bool ShouldComplete(PathSegment segment, Location pos, PlayerPhysics physics)
{
return segment.ExitTransition switch
{
PathTransitionType.ContinueStraight => TemplateHelper.IsNear(pos, segment.End, horizThresholdSq: 0.09),
PathTransitionType.PrepareJump => TemplateHelper.HasReachedSegmentEndPlane(pos, segment)
&& TemplateHelper.ProjectHorizontalSpeedAlongSegment(physics, segment) > 0.02,
_ => physics.OnGround && TemplateHelper.IsSettledOnTargetBlock(pos, segment.End, physics)
};
}
}
}

View file

@ -0,0 +1,60 @@
using System;
using MinecraftClient.Mapping;
using MinecraftClient.Physics;
namespace MinecraftClient.Pathing.Execution.Templates
{
public static class TemplateFootingHelper
{
private const double HalfWidth = PhysicsConsts.PlayerWidth / 2.0;
public static bool IsFootprintInsideTargetBlock(Location pos, Location target, double epsilon = 1.0E-4)
{
double minX = pos.X - HalfWidth;
double maxX = pos.X + HalfWidth;
double minZ = pos.Z - HalfWidth;
double maxZ = pos.Z + HalfWidth;
double blockMinX = Math.Floor(target.X);
double blockMaxX = blockMinX + 1.0;
double blockMinZ = Math.Floor(target.Z);
double blockMaxZ = blockMinZ + 1.0;
return minX >= blockMinX - epsilon
&& maxX <= blockMaxX + epsilon
&& minZ >= blockMinZ - epsilon
&& maxZ <= blockMaxZ + epsilon;
}
public static bool WillLeaveTargetBlockNextTick(Location pos, PlayerPhysics physics, Location target, double epsilon = 1.0E-4)
{
Location nextPos = new(
pos.X + physics.DeltaMovement.X,
pos.Y,
pos.Z + physics.DeltaMovement.Z);
return !IsFootprintInsideTargetBlock(nextPos, target, epsilon);
}
public static bool WillCrossSupportExitNextTick(Location pos, PlayerPhysics physics, PathSegment segment, double epsilon = 1.0E-4)
{
double nextX = pos.X + physics.DeltaMovement.X;
double nextZ = pos.Z + physics.DeltaMovement.Z;
double blockMinX = Math.Floor(segment.End.X);
double blockMaxX = blockMinX + 1.0;
double blockMinZ = Math.Floor(segment.End.Z);
double blockMaxZ = blockMinZ + 1.0;
if (segment.HeadingX > 0 && nextX > blockMaxX - HalfWidth + epsilon)
return true;
if (segment.HeadingX < 0 && nextX < blockMinX + HalfWidth - epsilon)
return true;
if (segment.HeadingZ > 0 && nextZ > blockMaxZ - HalfWidth + epsilon)
return true;
if (segment.HeadingZ < 0 && nextZ < blockMinZ + HalfWidth - epsilon)
return true;
return false;
}
}
}

View file

@ -90,14 +90,57 @@ namespace MinecraftClient.Pathing.Execution.Templates
input.Back = decision.HoldBack;
}
internal static bool HasReachedSegmentEndPlane(Location pos, PathSegment segment, double tolerance = 0.05)
{
GetNormalizedSegmentDirection(segment, out double dirX, out double dirZ);
double relX = pos.X - segment.End.X;
double relZ = pos.Z - segment.End.Z;
return relX * dirX + relZ * dirZ >= -tolerance;
}
internal static double ProjectHorizontalSpeedAlongSegment(PlayerPhysics physics, PathSegment segment)
{
GetNormalizedSegmentDirection(segment, out double dirX, out double dirZ);
return physics.DeltaMovement.X * dirX + physics.DeltaMovement.Z * dirZ;
}
internal static bool IsSettledOnTargetBlock(Location pos, Location target, PlayerPhysics physics,
double speedThresholdSq = 0.0016)
{
double horizontalSpeedSq = physics.DeltaMovement.X * physics.DeltaMovement.X
+ physics.DeltaMovement.Z * physics.DeltaMovement.Z;
return TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, target)
&& !TemplateFootingHelper.WillLeaveTargetBlockNextTick(pos, physics, target)
&& horizontalSpeedSq <= speedThresholdSq;
}
internal static bool IsSettledAtEnd(Location pos, Location target, PlayerPhysics physics,
double horizThresholdSq = 0.0025, double speedThresholdSq = 0.0016)
{
if (IsSettledOnTargetBlock(pos, target, physics, speedThresholdSq))
return true;
double dx = target.X - pos.X;
double dz = target.Z - pos.Z;
double horizontalSpeedSq = physics.DeltaMovement.X * physics.DeltaMovement.X
+ physics.DeltaMovement.Z * physics.DeltaMovement.Z;
return dx * dx + dz * dz <= horizThresholdSq && horizontalSpeedSq <= speedThresholdSq;
}
private static void GetNormalizedSegmentDirection(PathSegment segment, out double dirX, out double dirZ)
{
dirX = segment.End.X - segment.Start.X;
dirZ = segment.End.Z - segment.Start.Z;
double len = Math.Sqrt(dirX * dirX + dirZ * dirZ);
if (len < 1.0E-6)
{
dirX = 0.0;
dirZ = 0.0;
return;
}
dirX /= len;
dirZ /= len;
}
}
}

View file

@ -40,22 +40,21 @@ namespace MinecraftClient.Pathing.Execution.Templates
physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw);
physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch);
TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world);
TemplateHelper.ApplyDecision(input, decision);
if (decision.HoldBack)
TemplateHelper.FaceSegmentHeading(physics, _segment);
GroundedSegmentController.Apply(_segment, _nextSegment, pos, physics, input, world);
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))
if (GroundedSegmentController.ShouldComplete(_segment, pos, physics))
return TemplateState.Complete;
double movedSq = TemplateHelper.HorizontalDistanceSq(pos, _lastPos);
_stuckTicks = movedSq < 0.0005 ? _stuckTicks + 1 : 0;
_lastPos = pos;
int maxTicks = _segment.ExitTransition == PathTransitionType.ContinueStraight ? 100 : 140;
int maxTicks = _segment.ExitTransition switch
{
PathTransitionType.ContinueStraight => 100,
PathTransitionType.PrepareJump => 80,
_ => 140
};
if (_stuckTicks > 40 || _tickCount > maxTicks)
return TemplateState.Failed;