using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; namespace MinecraftClient.Mapping { /// /// Allows moving through a Minecraft world /// public static class Movement { /* ========= PATHFINDING METHODS ========= */ /// /// Handle movements due to gravity /// /// World the player is currently located in /// Location the player is currently at /// Current vertical motion speed /// Updated location after applying gravity public static Location HandleGravity(World world, Location location, ref double motionY) { if (Settings.InternalConfig.GravityEnabled) { Location onFoots = new(location.X, Math.Floor(location.Y), location.Z); Location belowFoots = Move(location, Direction.Down); if (location.Y > Math.Truncate(location.Y) + 0.0001) { belowFoots = location; belowFoots.Y = Math.Truncate(location.Y); } if (!IsOnGround(world, location) && !IsSwimming(world, location)) { while (!IsOnGround(world, belowFoots) && belowFoots.Y >= 1 + World.GetDimension().minY) belowFoots = Move(belowFoots, Direction.Down); location = Move2Steps(location, belowFoots, ref motionY, true).Dequeue(); } else if (!(world.GetBlock(onFoots).Type.IsSolid())) location = Move2Steps(location, onFoots, ref motionY, true).Dequeue(); } return location; } /// /// Return a list of possible moves for the player /// /// World the player is currently located in /// Location the player is currently at /// Allow possible but unsafe locations /// A list of new locations the player can move to public static IEnumerable GetAvailableMoves(World world, Location originLocation, bool allowUnsafe = false) { Location location = originLocation.ToCenter(); List availableMoves = new(); if (IsOnGround(world, location) || IsSwimming(world, location)) { foreach (Direction dir in Enum.GetValues(typeof(Direction))) { Location dest = Move(location, dir); if (CanMove(world, location, dir) && (allowUnsafe || IsSafe(world, dest))) availableMoves.Add(dest); } } else { foreach (Direction dir in new[] { Direction.East, Direction.West, Direction.North, Direction.South }) if (CanMove(world, location, dir) && IsOnGround(world, Move(location, dir)) && (allowUnsafe || IsSafe(world, Move(location, dir)))) availableMoves.Add(Move(location, dir)); availableMoves.Add(Move(location, Direction.Down)); } return availableMoves; } /// /// Decompose a single move from a block to another into several steps /// /// /// Allows moving by little steps instead or directly moving between blocks, /// which would be rejected by anti-cheat plugins anyway. /// /// Start location /// Destination location /// Current vertical motion speed /// Specify if performing falling steps /// Amount of steps by block /// A list of locations corresponding to the requested steps public static Queue Move2Steps(Location start, Location goal, ref double motionY, bool falling = false, int stepsByBlock = 8) { if (stepsByBlock <= 0) stepsByBlock = 1; if (falling) { //Use MC-Like falling algorithm double Y = start.Y; Queue fallSteps = new(); fallSteps.Enqueue(start); double motionPrev = motionY; motionY -= 0.08D; motionY *= 0.9800000190734863D; Y += motionY; if (Y < goal.Y) return new Queue(new[] { goal }); else return new Queue(new[] { new Location(start.X, Y, start.Z) }); } else { //Regular MCC moving algorithm motionY = 0; //Reset motion speed double totalStepsDouble = start.Distance(goal) * stepsByBlock; int totalSteps = (int)Math.Ceiling(totalStepsDouble); Location step = (goal - start) / totalSteps; if (totalStepsDouble >= 1) { Queue movementSteps = new(); for (int i = 1; i <= totalSteps; i++) movementSteps.Enqueue(start + step * i); return movementSteps; } else return new Queue(new[] { goal }); } } /// /// Calculate a path from the start location to the destination location /// /// /// Based on the A* pathfinding algorithm described on Wikipedia /// /// /// Start location /// Destination location /// Allow possible but unsafe locations /// If no valid path can be found, also allow locations within specified distance of destination /// Do not get closer of destination than specified distance /// How long to wait before stopping computation /// When location is unreachable, computation will reach timeout, then optionally fallback to a close location within maxOffset /// A list of locations, or null if calculation failed public static Queue? CalculatePath(World world, Location start, Location goal, bool allowUnsafe, int maxOffset, int minOffset, TimeSpan timeout) { CancellationTokenSource cts = new(); Task?> pathfindingTask = Task.Factory.StartNew(() => Movement.CalculatePath(world, start, goal, allowUnsafe, maxOffset, minOffset, cts.Token)); pathfindingTask.Wait(timeout); if (!pathfindingTask.IsCompleted) { cts.Cancel(); pathfindingTask.Wait(); } return pathfindingTask.Result; } /// /// Calculate a path from the start location to the destination location /// /// /// Based on the A* pathfinding algorithm described on Wikipedia /// /// /// Start location /// Destination location /// Allow possible but unsafe locations /// If no valid path can be found, also allow locations within specified distance of destination /// Do not get closer of destination than specified distance /// Token for stopping computation after a certain time /// A list of locations, or null if calculation failed public static Queue? CalculatePath(World world, Location start, Location goal, bool allowUnsafe, int maxOffset, int minOffset, CancellationToken ct) { // This is a bad configuration if (minOffset > maxOffset) throw new ArgumentException("minOffset must be lower or equal to maxOffset", nameof(minOffset)); // Round start coordinates for easier calculation Location startLower = start.ToFloor(); Location goalLower = goal.ToFloor(); // We always use distance squared so our limits must also be squared. minOffset *= minOffset; maxOffset *= maxOffset; ///---/// // Prepare variables and datastructures for A* ///---/// // Dictionary that contains the relation between all coordinates and resolves the final path Dictionary CameFrom = new(); // Create a Binary Heap for all open positions => Allows fast access to Nodes with lowest scores BinaryHeap openSet = new(); // Dictionary to keep track of the G-Score of every location Dictionary gScoreDict = new(); // Set start values for variables openSet.Insert(0, (int)startLower.DistanceSquared(goalLower), startLower); gScoreDict[startLower] = 0; BinaryHeap.Node? current = null; ///---/// // Start of A* ///---/// // Execute while we have nodes to process and we are not cancelled while (openSet.Count() > 0 && !ct.IsCancellationRequested) { // Get the root node of the Binary Heap // Node with the lowest F-Score or lowest H-Score on tie current = openSet.GetRootLocation(); // Return if goal found and no maxOffset was given OR current node is between minOffset and maxOffset if ((current.Location == goalLower && maxOffset <= 0) || (maxOffset > 0 && current.H_score >= minOffset && current.H_score <= maxOffset)) { return ReconstructPath(CameFrom, current.Location, start, goal); } // Discover neighbored blocks foreach (Location neighbor in GetAvailableMoves(world, current.Location, allowUnsafe)) { // If we are cancelled: break if (ct.IsCancellationRequested) break; // tentative_gScore is the distance from start to the neighbor through current int tentativeGScore = current.G_score + (int)current.Location.DistanceSquared(neighbor); // If the neighbor is not in the gScoreDict OR its current tentativeGScore is lower than the previously saved one: if (!gScoreDict.ContainsKey(neighbor) || (gScoreDict.ContainsKey(neighbor) && tentativeGScore < gScoreDict[neighbor])) { // Save the new relation between the neighbored block and the current one CameFrom[neighbor] = current.Location; gScoreDict[neighbor] = tentativeGScore; // If this location is not already included in the Binary Heap: save it if (!openSet.ContainsLocation(neighbor)) openSet.Insert(tentativeGScore, (int)neighbor.DistanceSquared(goalLower), neighbor); } } } //// Goal could not be reached. Set the path to the closest location if close enough if (current != null && openSet.MinH_ScoreNode != null && (maxOffset == int.MaxValue || openSet.MinH_ScoreNode.H_score <= maxOffset)) return ReconstructPath(CameFrom, openSet.MinH_ScoreNode.Location, start, goal); else return null; } /// /// Helper function for CalculatePath(). Backtrack from goal to start to reconstruct a step-by-step path. /// /// The collection of Locations that leads back to the start /// Endpoint of our later walk /// the path that leads to current from the start position private static Queue ReconstructPath(Dictionary Came_From, Location current, Location start, Location end) { int midPathCnt = 0; List total_path = new(); // Move from the center of the block to the final position if (current != end && current == end.ToFloor()) total_path.Add(end); // Generate intermediate paths total_path.Add(current.ToCenter()); while (Came_From.ContainsKey(current)) { ++midPathCnt; current = Came_From[current]; total_path.Add(current.ToCenter()); } if (midPathCnt <= 2 && start.DistanceSquared(end) < 2.0) return new Queue(new Location[] { end }); else { // Move to the center of the block first if (current != start && current == start.ToFloor()) total_path.Add(start.ToCenter()); total_path.Reverse(); return new Queue(total_path); } } /// /// A datastructure to store Locations as Nodes and provide them in sorted and queued order. /// !!! /// CAN BE REPLACED WITH PriorityQueue IN .NET-6 /// https://docs.microsoft.com/en-us/dotnet/api/system.collections.generic.priorityqueue-2?view=net-6.0 /// !!! /// public class BinaryHeap { /// /// Represents a location and its attributes /// public class Node { // Distance to start public int G_score; // Distance to Goal public int H_score; public int F_score { get { return H_score + G_score; } } public Location Location; public Node(int g_score, int h_score, Location loc) { G_score = g_score; H_score = h_score; Location = loc; } } // List which contains all nodes in form of a Binary Heap private readonly List heapList; // Hashset for quick checks of locations included in the heap private readonly HashSet locationList; public Node? MinH_ScoreNode; public BinaryHeap() { heapList = new List(); locationList = new HashSet(); MinH_ScoreNode = null; } /// /// Insert a new location in the heap /// /// G-Score of the location /// H-Score of the location /// The location public void Insert(int newG_Score, int newH_Score, Location loc) { // Begin at the end of the list int i = heapList.Count; // Temporarily save the node created with the parameters to allow comparisons Node newNode = new(newG_Score, newH_Score, loc); // Add new note to the end of the list heapList.Add(newNode); locationList.Add(loc); // Save node with the smallest H-Score => Distance to goal if (MinH_ScoreNode == null || newNode.H_score < MinH_ScoreNode.H_score) MinH_ScoreNode = newNode; // There is no need of sorting for one node. if (i > 0) { /// Go up the heap from child to parent and move parent down... // while we are not looking at the root node AND the new node has better attributes than the parent node ((i - 1) / 2) while (i > 0 && FirstNodeBetter(newNode /* Current Child */, heapList[(i - 1) / 2] /* Coresponding Parent */)) { // Move parent down and replace current child -> New free space is created heapList[i] = heapList[(i - 1) / 2]; // Select the next parent to check i = (i - 1) / 2; } /// Nodes were moved down at position I there is now a free space at the correct position for our new node: // Insert new node in position heapList[i] = newNode; } } /// /// Obtain the root which represents the node the the best attributes currently /// /// node with the best attributes currently /// public Node GetRootLocation() { // The heap is empty. There is nothing to return. if (heapList.Count == 0) { throw new InvalidOperationException("The heap is empty."); } // Save the root node Node rootNode = heapList[0]; locationList.Remove(rootNode.Location); // Temporarirly store the last item's value. Node lastNode = heapList[^1]; // Remove the last value. heapList.RemoveAt(heapList.Count - 1); if (heapList.Count > 0) { // Start at the first index. int currentParentPos = 0; /// Go through the heap from root to bottom... // Continue until the halfway point of the heap. while (currentParentPos < heapList.Count / 2) { // Select the left child of the current parent int currentChildPos = (2 * currentParentPos) + 1; // If the currently selected child is not the last entry of the list AND right child has better attributes if ((currentChildPos < heapList.Count - 1) && FirstNodeBetter(heapList[currentChildPos + 1], heapList[currentChildPos])) { // Select the right child currentChildPos++; } // If the last item is smaller than both siblings at the // current height, break. if (FirstNodeBetter(lastNode, heapList[currentChildPos])) { break; } // Move the item at index j up one level. heapList[currentParentPos] = heapList[currentChildPos]; // Move index i to the appropriate branch. currentParentPos = currentChildPos; } // Insert the last node into the currently free position heapList[currentParentPos] = lastNode; } return rootNode; } /// /// Compares two nodes and evaluates their position to the goal. /// /// First node to compare /// Second node to compare /// True if the first node has a more promissing position to the goal than the second private static bool FirstNodeBetter(Node firstNode, Node secondNode) { // Is the F_score smaller? return (firstNode.F_score < secondNode.F_score) || // If F_score is equal, evaluate the h-score (firstNode.F_score == secondNode.F_score && firstNode.H_score < secondNode.H_score); } /// /// Get the size of the heap /// /// size of the heap public int Count() { return heapList.Count; } /// /// Check if the heap contains a node with a certain location /// /// Location to check /// true if a node with the given location is in the heap public bool ContainsLocation(Location loc) { return locationList.Contains(loc); } } /* ========= LOCATION PROPERTIES ========= */ // TODO: Find a way to remove this Hack for Vines here. /// /// Check if the specified location is on the ground /// /// World for performing check /// Location to check /// True if the specified location is on the ground public static bool IsOnGround(World world, Location location) { ChunkColumn? chunkColumn = world.GetChunkColumn(location); if (chunkColumn == null || chunkColumn.FullyLoaded == false) return true; // avoid moving downward in a not loaded chunk Location down = Move(location, Direction.Down); Material currentMaterial = world.GetBlock(down).Type; bool result = currentMaterial.IsSolid() || currentMaterial == Material.TwistingVines || currentMaterial == Material.TwistingVinesPlant || currentMaterial == Material.WeepingVines || currentMaterial == Material.WeepingVinesPlant || currentMaterial == Material.Vine; bool northCheck = 1 + Math.Floor(down.Z) - down.Z > 0.7; bool eastCheck = down.X - Math.Floor(down.X) > 0.7; bool southCheck = down.Z - Math.Floor(down.Z) > 0.7; bool westCheck = 1 + Math.Floor(down.X) - down.X > 0.7; if (!result && northCheck) { Location locationDownNorth = Move(down, Direction.North); result |= world.GetBlock(locationDownNorth).Type.IsSolid() || world.GetBlock(locationDownNorth).Type == Material.TwistingVines || world.GetBlock(locationDownNorth).Type == Material.TwistingVinesPlant || world.GetBlock(locationDownNorth).Type == Material.WeepingVines || world.GetBlock(locationDownNorth).Type == Material.WeepingVinesPlant || world.GetBlock(locationDownNorth).Type == Material.Vine; } if (!result && northCheck && eastCheck) { Location locationDownNorthEast = Move(down, Direction.NorthEast); result |= world.GetBlock(locationDownNorthEast).Type.IsSolid() || world.GetBlock(locationDownNorthEast).Type == Material.TwistingVines || world.GetBlock(locationDownNorthEast).Type == Material.TwistingVinesPlant || world.GetBlock(locationDownNorthEast).Type == Material.WeepingVines || world.GetBlock(locationDownNorthEast).Type == Material.WeepingVinesPlant || world.GetBlock(locationDownNorthEast).Type == Material.Vine; } if (!result && eastCheck) { Location locationDownEast = Move(down, Direction.East); result |= world.GetBlock(locationDownEast).Type.IsSolid() || world.GetBlock(locationDownEast).Type == Material.TwistingVines || world.GetBlock(locationDownEast).Type == Material.TwistingVinesPlant || world.GetBlock(locationDownEast).Type == Material.WeepingVines || world.GetBlock(locationDownEast).Type == Material.WeepingVinesPlant || world.GetBlock(locationDownEast).Type == Material.Vine; } if (!result && eastCheck && southCheck) { Location locationDownSouthEast = Move(down, Direction.SouthEast); result |= world.GetBlock(locationDownSouthEast).Type.IsSolid() || world.GetBlock(locationDownSouthEast).Type == Material.TwistingVines || world.GetBlock(locationDownSouthEast).Type == Material.TwistingVinesPlant || world.GetBlock(locationDownSouthEast).Type == Material.WeepingVines || world.GetBlock(locationDownSouthEast).Type == Material.WeepingVinesPlant || world.GetBlock(locationDownSouthEast).Type == Material.Vine; } if (!result && southCheck) { Location locationDownSouth = Move(down, Direction.South); result |= world.GetBlock(locationDownSouth).Type.IsSolid() || world.GetBlock(locationDownSouth).Type == Material.TwistingVines || world.GetBlock(locationDownSouth).Type == Material.TwistingVinesPlant || world.GetBlock(locationDownSouth).Type == Material.WeepingVines || world.GetBlock(locationDownSouth).Type == Material.WeepingVinesPlant || world.GetBlock(locationDownSouth).Type == Material.Vine; } if (!result && southCheck && westCheck) { Location locationDownSouthWest = Move(down, Direction.SouthWest); result |= world.GetBlock(locationDownSouthWest).Type.IsSolid() || world.GetBlock(locationDownSouthWest).Type == Material.TwistingVines || world.GetBlock(locationDownSouthWest).Type == Material.TwistingVinesPlant || world.GetBlock(locationDownSouthWest).Type == Material.WeepingVines || world.GetBlock(locationDownSouthWest).Type == Material.WeepingVinesPlant || world.GetBlock(locationDownSouthWest).Type == Material.Vine; } if (!result && westCheck) { Location locationDownWest = Move(down, Direction.West); result |= world.GetBlock(locationDownWest).Type.IsSolid() || world.GetBlock(locationDownWest).Type == Material.TwistingVines || world.GetBlock(locationDownWest).Type == Material.TwistingVinesPlant || world.GetBlock(locationDownWest).Type == Material.WeepingVines || world.GetBlock(locationDownWest).Type == Material.WeepingVinesPlant || world.GetBlock(locationDownWest).Type == Material.Vine; } if (!result && westCheck && northCheck) { Location locationDownNorthWest = Move(down, Direction.NorthWest); result |= world.GetBlock(locationDownNorthWest).Type.IsSolid() || world.GetBlock(locationDownNorthWest).Type == Material.TwistingVines || world.GetBlock(locationDownNorthWest).Type == Material.TwistingVinesPlant || world.GetBlock(locationDownNorthWest).Type == Material.WeepingVines || world.GetBlock(locationDownNorthWest).Type == Material.WeepingVinesPlant || world.GetBlock(locationDownNorthWest).Type == Material.Vine; } return result && (location.Y <= Math.Truncate(location.Y) + 0.0001); } /// /// Check if the specified location implies swimming /// /// World for performing check /// Location to check /// True if the specified location implies swimming public static bool IsSwimming(World world, Location location) { return world.GetBlock(location).Type.IsLiquid(); } /// /// Check if the specified location can be climbed on /// /// World for performing check /// Location to check /// True if the specified location can be climbed on public static bool IsClimbing(World world, Location location) { return world.GetBlock(location).Type.CanBeClimbedOn(); } /// /// Check if the specified location is safe /// /// World for performing check /// Location to check /// True if the destination location won't directly harm the player public static bool IsSafe(World world, Location location) { return //No block that can harm the player !world.GetBlock(location).Type.CanHarmPlayers() && !world.GetBlock(Move(location, Direction.Up)).Type.CanHarmPlayers() && !world.GetBlock(Move(location, Direction.Down)).Type.CanHarmPlayers() //No fall from a too high place && (world.GetBlock(Move(location, Direction.Down)).Type.IsSolid() || IsClimbing(world, Move(location, Direction.Down)) || world.GetBlock(Move(location, Direction.Down, 2)).Type.IsSolid() || IsClimbing(world, Move(location, Direction.Down, 2)) || world.GetBlock(Move(location, Direction.Down, 3)).Type.IsSolid() || IsClimbing(world, Move(location, Direction.Down, 3))) //Not an underwater location && !(world.GetBlock(Move(location, Direction.Up)).Type.IsLiquid()); } /* ========= SIMPLE MOVEMENTS ========= */ /// /// Check if the player can move in the specified direction /// /// World the player is currently located in /// Location the player is currently at /// Direction the player is moving to /// True if the player can move in the specified direction public static bool CanMove(World world, Location location, Direction direction) { switch (direction) { // Move vertical case Direction.Down: return IsClimbing(world, Move(location, Direction.Down)) || !IsOnGround(world, location); case Direction.Up: bool nextTwoBlocks = !world.GetBlock(Move(Move(location, Direction.Up), Direction.Up)).Type.IsSolid(); // Check if the current block can be climbed on if (IsClimbing(world, location)) // Check if next block after the next one can be climbed uppon return IsClimbing(world, Move(location, Direction.Up)) || nextTwoBlocks; return (IsOnGround(world, location) || IsSwimming(world, location)) && nextTwoBlocks; // Move horizontal case Direction.East: case Direction.West: case Direction.South: case Direction.North: return PlayerFitsHere(world, Move(location, direction)); // Move diagonal case Direction.NorthEast: return PlayerFitsHere(world, Move(location, Direction.North)) && PlayerFitsHere(world, Move(location, Direction.East)) && PlayerFitsHere(world, Move(location, direction)); case Direction.SouthEast: return PlayerFitsHere(world, Move(location, Direction.South)) && PlayerFitsHere(world, Move(location, Direction.East)) && PlayerFitsHere(world, Move(location, direction)); case Direction.SouthWest: return PlayerFitsHere(world, Move(location, Direction.South)) && PlayerFitsHere(world, Move(location, Direction.West)) && PlayerFitsHere(world, Move(location, direction)); case Direction.NorthWest: return PlayerFitsHere(world, Move(location, Direction.North)) && PlayerFitsHere(world, Move(location, Direction.West)) && PlayerFitsHere(world, Move(location, direction)); default: throw new ArgumentException("Unknown direction", nameof(direction)); } } /// /// Evaluates if a player fits in this location /// /// Current world /// Location to check /// True if a player is able to stand in this location public static bool PlayerFitsHere(World world, Location location) { return (IsClimbing(world, location) && IsClimbing(world, Move(location, Direction.Up))) || !world.GetBlock(location).Type.IsSolid() && !world.GetBlock(Move(location, Direction.Up)).Type.IsSolid(); } /// /// Get an updated location for moving in the specified direction /// /// Current location /// Direction to move to /// Distance, in blocks /// Updated location public static Location Move(Location location, Direction direction, int length = 1) { return location + Move(direction) * length; } /// /// Get a location delta for moving in the specified direction /// /// Direction to move to /// A location delta for moving in that direction public static Location Move(Direction direction) { switch (direction) { // Move vertical case Direction.Down: return new Location(0, -1, 0); case Direction.Up: return new Location(0, 1, 0); // Move horizontal straight case Direction.East: return new Location(1, 0, 0); case Direction.West: return new Location(-1, 0, 0); case Direction.South: return new Location(0, 0, 1); case Direction.North: return new Location(0, 0, -1); // Move horizontal diagonal case Direction.NorthEast: return Move(Direction.North) + Move(Direction.East); case Direction.SouthEast: return Move(Direction.South) + Move(Direction.East); case Direction.SouthWest: return Move(Direction.South) + Move(Direction.West); case Direction.NorthWest: return Move(Direction.North) + Move(Direction.West); default: throw new ArgumentException("Unknown direction", nameof(direction)); } } /// /// Check that the chunks at both the start and destination locations have been loaded /// /// Current world /// Start location /// Destination location /// Is loading complete public static bool CheckChunkLoading(World world, Location start, Location dest) { ChunkColumn? chunkColumn = world.GetChunkColumn(dest); if (chunkColumn == null || chunkColumn.FullyLoaded == false) return false; chunkColumn = world.GetChunkColumn(start); if (chunkColumn == null || chunkColumn.FullyLoaded == false) return false; return true; } } }