diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index cb9a8fe5..cb52b7ff 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -80,6 +80,7 @@ namespace MinecraftClient private readonly MovementInput physicsInput = new(); private bool physicsInitialized = false; private Location? pathTarget; // Current waypoint for physics-driven pathfinding + private Pathing.Execution.PathSegmentManager? pathSegmentManager; public enum MovementType { Sneak, Walk, Sprint } private int sequenceId; // User for player block synchronization (Aka. digging, placing blocks, etc..) private bool CanSendMessage = false; @@ -1723,7 +1724,8 @@ namespace MinecraftClient { lock (locationLock) { - var ctx = new Pathing.Core.CalculationContext(world); + var ctx = new Pathing.Core.CalculationContext(world, + allowParkour: true, allowParkourAscend: true); var finder = new Pathing.Core.AStarPathFinder(); finder.DebugLog = msg => Log.Debug(msg); @@ -1731,7 +1733,6 @@ namespace MinecraftClient int sy = (int)Math.Floor(location.Y); int sz = (int)Math.Floor(location.Z); - // If floored Y lands inside a solid block (e.g. player on top of it), step up if (!ctx.CanWalkThrough(sx, sy, sz) && ctx.CanWalkThrough(sx, sy + 1, sz)) sy++; @@ -1746,8 +1747,8 @@ namespace MinecraftClient $"[raw pos=({location.X:F2},{location.Y:F2},{location.Z:F2})]"); using var cts = new CancellationTokenSource(); - var result = finder.Calculate(ctx, sx, sy, sz, - new Pathing.Goals.GoalBlock(gx, gy, gz), cts.Token, timeoutMs); + var pathGoal = new Pathing.Goals.GoalBlock(gx, gy, gz); + var result = finder.Calculate(ctx, sx, sy, sz, pathGoal, cts.Token, timeoutMs); Log.Info($"[Goto] A* result: {result.Status}, nodes={result.NodesExplored}, " + $"time={result.ElapsedMs}ms, path length={result.Path.Count}"); @@ -1758,27 +1759,23 @@ namespace MinecraftClient result.NodesExplored, result.ElapsedMs)); } - var queue = new Queue(); for (int i = 1; i < result.Path.Count; i++) { var node = result.Path[i]; - queue.Enqueue(new Location(node.X + 0.5, node.Y, node.Z + 0.5)); - } - - Log.Info($"[Goto] Path waypoints: {queue.Count}"); - int logCount = 0; - foreach (var wp in queue) - { - Log.Debug($"[Goto] wp[{logCount}] = ({wp.X:F1},{wp.Y:F1},{wp.Z:F1})"); - logCount++; + Log.Debug($"[Goto] seg[{i - 1}] = {node.MoveUsed}: ({node.X},{node.Y},{node.Z})"); } pathTarget = null; - path = queue; + path = null; + + pathSegmentManager = new Pathing.Execution.PathSegmentManager( + debugLog: msg => Log.Debug(msg), + infoLog: msg => Log.Info(msg)); + pathSegmentManager.StartNavigation(pathGoal, result); string statusStr = result.Status == Pathing.Core.PathStatus.Partial ? " (partial)" : ""; return (true, string.Format(Translations.cmd_goto_success, - queue.Count, result.NodesExplored, result.ElapsedMs, statusStr)); + result.Path.Count - 1, result.NodesExplored, result.ElapsedMs, statusStr)); } } @@ -3261,26 +3258,30 @@ namespace MinecraftClient } /// - /// Drive the physics engine input based on the current A* path. - /// Converts discrete waypoint pathfinding into continuous movement input. + /// Drive the physics engine input based on the current path. + /// Uses template-based PathSegmentManager when available, falls back to legacy waypoints. /// private void UpdatePathfindingInput() { physicsInput.Reset(); - // Advance waypoints when reached + // Template-based execution (new system) + if (pathSegmentManager is not null && pathSegmentManager.IsNavigating) + { + pathSegmentManager.Tick(location, playerPhysics, physicsInput, world); + playerYaw = playerPhysics.Yaw; + return; + } + + // Legacy waypoint-based execution if (pathTarget is not null && ReachedWaypoint(pathTarget.Value)) AdvanceWaypoint(); - // First target from a fresh path if (pathTarget is null && path is not null && path.Count > 0) AdvanceWaypoint(); if (pathTarget is not null) { - // Look-ahead: if this is a vertical-only waypoint and the next requires - // horizontal movement, merge them once we're close enough vertically. - // This handles the ladder-to-platform transition. if (path is not null && path.Count > 0) { var target = pathTarget.Value; @@ -3297,8 +3298,6 @@ namespace MinecraftClient double ndz = next.Z - target.Z; bool nextIsHorizontal = ndx * ndx + ndz * ndz > 0.3; - // Skip to next waypoint early if we're within 1 block of the target Y - // and the next move requires horizontal movement if (nextIsHorizontal && Math.Abs(dy) < 1.0) { AdvanceWaypoint(); @@ -3421,7 +3420,14 @@ namespace MinecraftClient /// true if a movement is currently handled public bool ClientIsMoving() { - return terrainAndMovementsEnabled && locationReceived && path is not null && path.Count > 0; + if (terrainAndMovementsEnabled && locationReceived) + { + if (pathSegmentManager is not null && pathSegmentManager.IsNavigating) + return true; + if (path is not null && path.Count > 0) + return true; + } + return false; } /// @@ -3441,6 +3447,9 @@ namespace MinecraftClient { bool success = ClientIsMoving(); path = null; + pathTarget = null; + pathSegmentManager?.Cancel(); + pathSegmentManager = null; return success; } diff --git a/MinecraftClient/Pathing/Core/AStarPathFinder.cs b/MinecraftClient/Pathing/Core/AStarPathFinder.cs index a4c68b93..fa2e1432 100644 --- a/MinecraftClient/Pathing/Core/AStarPathFinder.cs +++ b/MinecraftClient/Pathing/Core/AStarPathFinder.cs @@ -49,6 +49,19 @@ namespace MinecraftClient.Pathing.Core moves.Add(new MoveFall()); + foreach (int dx in offsets) + { + for (int dist = 2; dist <= 3; dist++) + moves.Add(new MoveParkour(dx, 0, dist)); + moves.Add(new MoveParkour(dx, 0, 2, yDelta: 1)); + } + foreach (int dz in offsets) + { + for (int dist = 2; dist <= 3; dist++) + moves.Add(new MoveParkour(0, dz, dist)); + moves.Add(new MoveParkour(0, dz, 2, yDelta: 1)); + } + return [.. moves]; } diff --git a/MinecraftClient/Pathing/Execution/ActionTemplateFactory.cs b/MinecraftClient/Pathing/Execution/ActionTemplateFactory.cs new file mode 100644 index 00000000..eff8aa42 --- /dev/null +++ b/MinecraftClient/Pathing/Execution/ActionTemplateFactory.cs @@ -0,0 +1,27 @@ +using System; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution.Templates; + +namespace MinecraftClient.Pathing.Execution +{ + /// + /// Maps a PathSegment (MoveType + start/end) to the appropriate IActionTemplate. + /// + public static class ActionTemplateFactory + { + public static IActionTemplate Create(PathSegment segment) + { + 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), + _ => throw new ArgumentException($"Unknown MoveType: {segment.MoveType}") + }; + } + } +} diff --git a/MinecraftClient/Pathing/Execution/IActionTemplate.cs b/MinecraftClient/Pathing/Execution/IActionTemplate.cs new file mode 100644 index 00000000..dac2c44c --- /dev/null +++ b/MinecraftClient/Pathing/Execution/IActionTemplate.cs @@ -0,0 +1,25 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution +{ + public enum TemplateState + { + InProgress, + Complete, + Failed + } + + /// + /// Per-tick movement controller for one path segment. + /// Reads player state from physics, writes desired input to MovementInput, + /// and reports completion or failure. + /// + public interface IActionTemplate + { + Location ExpectedStart { get; } + Location ExpectedEnd { get; } + + TemplateState Tick(Location currentPos, PlayerPhysics physics, MovementInput input); + } +} diff --git a/MinecraftClient/Pathing/Execution/PathExecutor.cs b/MinecraftClient/Pathing/Execution/PathExecutor.cs new file mode 100644 index 00000000..78270ee5 --- /dev/null +++ b/MinecraftClient/Pathing/Execution/PathExecutor.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using MinecraftClient.Mapping; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution +{ + public enum PathExecutorState + { + InProgress, + Failed, + Complete + } + + /// + /// Drives a sequence of PathSegments by instantiating the correct IActionTemplate + /// for each segment and ticking it every game tick. + /// + public sealed class PathExecutor + { + private readonly List _segments; + private int _currentIndex; + private IActionTemplate? _currentTemplate; + private readonly Action? _debugLog; + + public bool IsComplete => _currentIndex >= _segments.Count && _currentTemplate is null; + public int CurrentIndex => _currentIndex; + public int TotalSegments => _segments.Count; + public PathSegment? CurrentSegment => + _currentIndex < _segments.Count ? _segments[_currentIndex] : null; + + public PathExecutor(List segments, Action? debugLog = null) + { + _segments = segments; + _currentIndex = 0; + _debugLog = debugLog; + AdvanceToNextSegment(); + } + + public PathExecutorState Tick(Location pos, PlayerPhysics physics, MovementInput input) + { + if (_currentTemplate is null) + return PathExecutorState.Complete; + + var state = _currentTemplate.Tick(pos, physics, input); + + switch (state) + { + case TemplateState.Complete: + _debugLog?.Invoke($"[PathExec] Segment {_currentIndex} complete " + + $"({_segments[_currentIndex].MoveType}) at ({pos.X:F2},{pos.Y:F2},{pos.Z:F2})"); + _currentIndex++; + if (_currentIndex >= _segments.Count) + { + _currentTemplate = null; + _debugLog?.Invoke("[PathExec] All segments complete!"); + return PathExecutorState.Complete; + } + AdvanceToNextSegment(); + return PathExecutorState.InProgress; + + case TemplateState.Failed: + _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})"); + return PathExecutorState.Failed; + + default: + return PathExecutorState.InProgress; + } + } + + private void AdvanceToNextSegment() + { + if (_currentIndex < _segments.Count) + { + var seg = _segments[_currentIndex]; + _currentTemplate = ActionTemplateFactory.Create(seg); + _debugLog?.Invoke($"[PathExec] Starting segment {_currentIndex}/{_segments.Count}: {seg}"); + } + else + { + _currentTemplate = null; + } + } + } +} diff --git a/MinecraftClient/Pathing/Execution/PathSegment.cs b/MinecraftClient/Pathing/Execution/PathSegment.cs new file mode 100644 index 00000000..ec3f0a76 --- /dev/null +++ b/MinecraftClient/Pathing/Execution/PathSegment.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Execution +{ + public sealed class PathSegment + { + public required Location Start { get; init; } + public required Location End { get; init; } + public required MoveType MoveType { 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 override string ToString() => + $"{MoveType}: ({Start.X:F1},{Start.Y:F1},{Start.Z:F1})->({End.X:F1},{End.Y:F1},{End.Z:F1})"; + } +} diff --git a/MinecraftClient/Pathing/Execution/PathSegmentManager.cs b/MinecraftClient/Pathing/Execution/PathSegmentManager.cs new file mode 100644 index 00000000..c6492cc0 --- /dev/null +++ b/MinecraftClient/Pathing/Execution/PathSegmentManager.cs @@ -0,0 +1,120 @@ +using System; +using System.Threading; +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Goals; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution +{ + /// + /// Top-level navigation controller. Holds a PathExecutor, monitors its progress, + /// and triggers replanning on failure or deviation. + /// + public sealed class PathSegmentManager + { + private PathExecutor? _executor; + private IGoal? _goal; + private int _replanCount; + private const int MaxReplans = 5; + + private readonly Action? _debugLog; + private readonly Action? _infoLog; + + public bool IsNavigating => _executor is not null && !_executor.IsComplete; + public int ReplanCount => _replanCount; + + public PathSegmentManager(Action? debugLog = null, Action? infoLog = null) + { + _debugLog = debugLog; + _infoLog = infoLog; + } + + public void StartNavigation(IGoal goal, PathResult result) + { + _goal = goal; + _replanCount = 0; + var segments = PathSegment.FromPath(result.Path); + _executor = new PathExecutor(segments, _debugLog); + _infoLog?.Invoke($"[PathMgr] Navigation started: {segments.Count} segments"); + } + + public void Tick(Location pos, PlayerPhysics physics, MovementInput input, World world) + { + if (_executor is null) + return; + + var state = _executor.Tick(pos, physics, input); + + switch (state) + { + case PathExecutorState.Complete: + _infoLog?.Invoke("[PathMgr] Navigation complete!"); + _executor = null; + _goal = null; + break; + + case PathExecutorState.Failed: + _infoLog?.Invoke("[PathMgr] Segment failed, replanning..."); + Replan(pos, world); + break; + } + } + + public void Cancel() + { + if (_executor is not null) + { + _infoLog?.Invoke("[PathMgr] Navigation cancelled."); + _executor = null; + _goal = null; + } + } + + private void Replan(Location pos, World world) + { + _replanCount++; + if (_replanCount > MaxReplans) + { + _infoLog?.Invoke($"[PathMgr] Giving up after {MaxReplans} replans."); + _executor = null; + _goal = null; + return; + } + + if (_goal is null) + { + _executor = null; + return; + } + + _debugLog?.Invoke($"[PathMgr] Replan #{_replanCount} from ({pos.X:F2},{pos.Y:F2},{pos.Z:F2})"); + + var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true); + var finder = new AStarPathFinder(); + finder.DebugLog = _debugLog; + + int sx = (int)Math.Floor(pos.X); + int sy = (int)Math.Floor(pos.Y); + int sz = (int)Math.Floor(pos.Z); + + if (!ctx.CanWalkThrough(sx, sy, sz) && ctx.CanWalkThrough(sx, sy + 1, sz)) + sy++; + + using var cts = new CancellationTokenSource(); + var result = finder.Calculate(ctx, sx, sy, sz, _goal, cts.Token, 3000); + + if (result.Status == PathStatus.Failed || result.Path.Count < 2) + { + _infoLog?.Invoke("[PathMgr] Replan failed -- no path found."); + _executor = null; + _goal = null; + return; + } + + var segments = PathSegment.FromPath(result.Path); + _executor = new PathExecutor(segments, _debugLog); + _infoLog?.Invoke($"[PathMgr] Replanned: {segments.Count} segments (replan #{_replanCount})"); + } + } +} diff --git a/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs new file mode 100644 index 00000000..393cfb66 --- /dev/null +++ b/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs @@ -0,0 +1,59 @@ +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution.Templates +{ + /// + /// Jump up 1 block while moving 1 block in a cardinal direction. + /// Faces destination, sprints forward, and jumps when on ground. + /// + public sealed class AscendTemplate : IActionTemplate + { + public Location ExpectedStart { get; } + public Location ExpectedEnd { get; } + + private int _tickCount; + private Location _lastPos; + private int _stuckTicks; + + public AscendTemplate(Location start, Location end) + { + ExpectedStart = start; + ExpectedEnd = end; + _lastPos = start; + } + + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) + { + _tickCount++; + + double dx = ExpectedEnd.X - pos.X; + double dz = ExpectedEnd.Z - pos.Z; + double dy = ExpectedEnd.Y - pos.Y; + double horizDistSq = dx * dx + dz * dz; + + // Complete when close to destination. Sprint bouncing can leave the player + // slightly above ground, so we don't require OnGround here. + 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); + _stuckTicks = (movedSq < 0.0005 && movedY < 0.001) ? _stuckTicks + 1 : 0; + _lastPos = pos; + + if (_stuckTicks > 40 || _tickCount > 80) + return TemplateState.Failed; + + physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); + input.Forward = true; + input.Sprint = true; + + if (physics.OnGround && dy > 0.1) + input.Jump = true; + + return TemplateState.InProgress; + } + } +} diff --git a/MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs new file mode 100644 index 00000000..bb7d810f --- /dev/null +++ b/MinecraftClient/Pathing/Execution/Templates/ClimbTemplate.cs @@ -0,0 +1,63 @@ +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution.Templates +{ + /// + /// Climb up or down a ladder/vine by 1 block. + /// Pushes against the wall (Forward + face center) and jumps for upward movement. + /// + public sealed class ClimbTemplate : IActionTemplate + { + public Location ExpectedStart { get; } + public Location ExpectedEnd { get; } + + private int _tickCount; + + public ClimbTemplate(Location start, Location end) + { + ExpectedStart = start; + ExpectedEnd = end; + } + + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) + { + _tickCount++; + + double dy = ExpectedEnd.Y - pos.Y; + double dx = ExpectedEnd.X - pos.X; + double dz = ExpectedEnd.Z - pos.Z; + double horizDistSq = dx * dx + dz * dz; + + if (Math.Abs(dy) < 0.3 && horizDistSq < 0.5) + return TemplateState.Complete; + + if (_tickCount > 100) + return TemplateState.Failed; + + if (physics.OnClimbable) + { + if (dy > 0) + { + input.Jump = true; + input.Forward = true; + if (horizDistSq > 0.01) + physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); + } + // Going down: don't press anything, gravity + climbable friction handles it + } + else + { + // Left the climbable area -- walk toward destination + if (horizDistSq > 0.01) + { + physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); + input.Forward = true; + } + } + + return TemplateState.InProgress; + } + } +} diff --git a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs new file mode 100644 index 00000000..26f86f6c --- /dev/null +++ b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs @@ -0,0 +1,55 @@ +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution.Templates +{ + /// + /// Walk off a ledge and drop 1-N blocks to a landing spot. + /// Walks toward the destination; gravity handles the fall. + /// + public sealed class DescendTemplate : IActionTemplate + { + public Location ExpectedStart { get; } + public Location ExpectedEnd { get; } + + private int _tickCount; + private bool _hasFallen; + + public DescendTemplate(Location start, Location end) + { + ExpectedStart = start; + ExpectedEnd = end; + } + + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) + { + _tickCount++; + + double dx = ExpectedEnd.X - pos.X; + double dz = ExpectedEnd.Z - pos.Z; + double dy = ExpectedEnd.Y - pos.Y; + double horizDistSq = dx * dx + dz * dz; + + if (!physics.OnGround) + _hasFallen = true; + + if (_hasFallen && physics.OnGround && horizDistSq < 0.5 && Math.Abs(dy) < 0.8) + return TemplateState.Complete; + + if (horizDistSq < 0.25 && Math.Abs(dy) < 0.5 && physics.OnGround) + return TemplateState.Complete; + + if (_tickCount > 120) + return TemplateState.Failed; + + if (horizDistSq > 0.01) + { + physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); + input.Forward = true; + } + + return TemplateState.InProgress; + } + } +} diff --git a/MinecraftClient/Pathing/Execution/Templates/FallTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/FallTemplate.cs new file mode 100644 index 00000000..47e640a0 --- /dev/null +++ b/MinecraftClient/Pathing/Execution/Templates/FallTemplate.cs @@ -0,0 +1,42 @@ +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution.Templates +{ + /// + /// Vertical free fall at the same X,Z. Waits for the player to land at the target Y. + /// + public sealed class FallTemplate : IActionTemplate + { + public Location ExpectedStart { get; } + public Location ExpectedEnd { get; } + + private int _tickCount; + private bool _hasFallen; + + public FallTemplate(Location start, Location end) + { + ExpectedStart = start; + ExpectedEnd = end; + } + + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) + { + _tickCount++; + + double dy = pos.Y - ExpectedEnd.Y; + + if (!physics.OnGround) + _hasFallen = true; + + if (_hasFallen && physics.OnGround && Math.Abs(dy) < 1.0) + return TemplateState.Complete; + + if (_tickCount > 200) + return TemplateState.Failed; + + return TemplateState.InProgress; + } + } +} diff --git a/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs new file mode 100644 index 00000000..f531ff9b --- /dev/null +++ b/MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs @@ -0,0 +1,79 @@ +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution.Templates +{ + /// + /// Sprint-jump across a gap. Uses a phase-based state machine: + /// Approach -> jump on first available ground tick -> Airborne -> Landing check. + /// + public sealed class SprintJumpTemplate : IActionTemplate + { + private enum Phase { Approach, Airborne, Landing } + + public Location ExpectedStart { get; } + public Location ExpectedEnd { get; } + + private int _tickCount; + private Phase _phase = Phase.Approach; + private readonly int _distance; + + public SprintJumpTemplate(Location start, Location end) + { + ExpectedStart = start; + ExpectedEnd = end; + + double dx = Math.Abs(end.X - start.X); + double dz = Math.Abs(end.Z - start.Z); + _distance = (int)Math.Round(Math.Max(dx, dz)); + } + + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) + { + _tickCount++; + + double dx = ExpectedEnd.X - pos.X; + double dz = ExpectedEnd.Z - pos.Z; + double dy = ExpectedEnd.Y - pos.Y; + double horizDistSq = dx * dx + dz * dz; + + physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); + input.Forward = true; + input.Sprint = true; + + switch (_phase) + { + case Phase.Approach: + if (physics.OnGround) + { + input.Jump = true; + _phase = Phase.Airborne; + } + if (_tickCount > 20) + return TemplateState.Failed; + break; + + case Phase.Airborne: + if (!physics.OnGround) + break; + // Landed + _phase = Phase.Landing; + goto case Phase.Landing; + + case Phase.Landing: + if (horizDistSq < 2.0 && Math.Abs(dy) < 1.0) + return TemplateState.Complete; + return TemplateState.Failed; + } + + if (pos.Y < ExpectedEnd.Y - 4.0) + return TemplateState.Failed; + + if (_tickCount > 60) + return TemplateState.Failed; + + return TemplateState.InProgress; + } + } +} diff --git a/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs b/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs new file mode 100644 index 00000000..18a67a66 --- /dev/null +++ b/MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs @@ -0,0 +1,31 @@ +using System; +using MinecraftClient.Mapping; + +namespace MinecraftClient.Pathing.Execution.Templates +{ + internal static class TemplateHelper + { + internal static float CalculateYaw(double dx, double dz) + { + float yaw = (float)(-Math.Atan2(dx, dz) / Math.PI * 180.0); + if (yaw < 0) yaw += 360; + return yaw; + } + + internal static double HorizontalDistanceSq(Location a, Location b) + { + double dx = a.X - b.X; + double dz = a.Z - b.Z; + return dx * dx + dz * dz; + } + + internal static bool IsNear(Location pos, Location target, + double horizThresholdSq = 0.25, double vertThreshold = 0.8) + { + double dx = target.X - pos.X; + double dz = target.Z - pos.Z; + double dy = target.Y - pos.Y; + return dx * dx + dz * dz < horizThresholdSq && Math.Abs(dy) < vertThreshold; + } + } +} diff --git a/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs new file mode 100644 index 00000000..9c9f430c --- /dev/null +++ b/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs @@ -0,0 +1,51 @@ +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution.Templates +{ + /// + /// Walk/sprint toward a destination on the same Y level. + /// Used for Traverse and Diagonal moves. + /// + public sealed class WalkTemplate : IActionTemplate + { + public Location ExpectedStart { get; } + public Location ExpectedEnd { get; } + + private int _tickCount; + private Location _lastPos; + private int _stuckTicks; + + public WalkTemplate(Location start, Location end) + { + ExpectedStart = start; + ExpectedEnd = end; + _lastPos = start; + } + + public TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input) + { + _tickCount++; + + if (TemplateHelper.IsNear(pos, ExpectedEnd, horizThresholdSq: 0.20)) + return TemplateState.Complete; + + double movedSq = TemplateHelper.HorizontalDistanceSq(pos, _lastPos); + _stuckTicks = movedSq < 0.0005 ? _stuckTicks + 1 : 0; + _lastPos = pos; + + if (_stuckTicks > 40 || _tickCount > 100) + return TemplateState.Failed; + + double dx = ExpectedEnd.X - pos.X; + double dz = ExpectedEnd.Z - pos.Z; + physics.Yaw = TemplateHelper.CalculateYaw(dx, dz); + + input.Forward = true; + input.Sprint = true; + + return TemplateState.InProgress; + } + } +} diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs b/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs new file mode 100644 index 00000000..599a5bf5 --- /dev/null +++ b/MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs @@ -0,0 +1,113 @@ +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Moves.Impl +{ + /// + /// Sprint jump across a gap of 1-3 blocks (total distance 2-4 blocks forward). + /// Optionally ascends 1 block during the jump (distance 2 only). + /// Requires AllowParkour in context; the first block forward must lack ground. + /// + public sealed class MoveParkour : IMove + { + public MoveType Type => MoveType.Parkour; + public int XOffset { get; } + public int ZOffset { get; } + public bool DynamicY => false; + + private readonly int _distance; + private readonly int _yDelta; + private readonly int _xDir; + private readonly int _zDir; + + public MoveParkour(int xDir, int zDir, int distance, int yDelta = 0) + { + _xDir = xDir; + _zDir = zDir; + _distance = distance; + _yDelta = yDelta; + XOffset = xDir * distance; + ZOffset = zDir * distance; + } + + public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result) + { + if (!ctx.AllowParkour) + { + result.SetImpossible(); + return; + } + + if (_yDelta > 0 && !ctx.AllowParkourAscend) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanSprint) + { + result.SetImpossible(); + return; + } + + int destX = x + _xDir * _distance; + int destZ = z + _zDir * _distance; + int destY = y + _yDelta; + + if (!ctx.CanWalkThrough(x, y + 2, z)) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanWalkOn(destX, destY - 1, destZ)) + { + result.SetImpossible(); + return; + } + + if (!ctx.CanWalkThrough(destX, destY, destZ) || + !ctx.CanWalkThrough(destX, destY + 1, destZ)) + { + result.SetImpossible(); + return; + } + + for (int i = 1; i < _distance; i++) + { + int gx = x + _xDir * i; + int gz = z + _zDir * i; + + if (!ctx.CanWalkThrough(gx, y, gz) || + !ctx.CanWalkThrough(gx, y + 1, gz) || + !ctx.CanWalkThrough(gx, y + 2, gz)) + { + result.SetImpossible(); + return; + } + + if (_yDelta > 0 && !ctx.CanWalkThrough(gx, y + 3, gz)) + { + result.SetImpossible(); + return; + } + } + + int firstGapX = x + _xDir; + int firstGapZ = z + _zDir; + if (ctx.CanWalkOn(firstGapX, y - 1, firstGapZ)) + { + result.SetImpossible(); + return; + } + + double cost = _distance * ctx.SprintCost + ctx.JumpPenalty; + if (_yDelta > 0) + cost += ctx.JumpPenalty; + + result.Set(destX, destY, destZ, cost); + } + + public override string ToString() => + $"MoveParkour(dir=({_xDir},{_zDir}), dist={_distance}, dy={_yDelta})"; + } +}