feat: add transition-aware path execution braking

This commit is contained in:
BruceChen 2026-04-12 18:46:42 +08:00
parent 945eae958a
commit 3b4e552d70
23 changed files with 837 additions and 108 deletions

View file

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MinecraftClient\MinecraftClient.csproj" />
</ItemGroup>
</Project>

View file

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

View file

@ -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<PathSegment>
{
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);
}
}

View file

@ -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<PathSegment> 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<PathSegment> 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<PathSegment> segments = PathSegmentBuilder.FromPath(nodes);
Assert.Equal(PathTransitionType.PrepareJump, segments[0].ExitTransition);
Assert.True(segments[0].PreserveSprint);
}
private static List<PathNode> BuildNodes(params (int x, int y, int z, MoveType moveUsed)[] raw)
{
var result = new List<PathNode>(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;
}
}

View file

@ -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);
}
}

View file

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