/** * @file path.cpp * * Implementation of the path finding algorithms. */ #include "engine/path.h" #include #include #include #include #include #include #include #include #include "appfat.h" #include "crawl.hpp" #include "engine/displacement.hpp" #include "engine/point.hpp" #include "utils/algorithm/container.hpp" #include "utils/static_vector.hpp" namespace devilution { // The frame times for axis-aligned and diagonal steps are actually the same. // // However, we set the diagonal step cost a bit higher to avoid // excessive diagonal movement. For example, the frame times for these // two paths are the same: ↑↑ and ↗↖. However, ↑↑ looks more natural. const int PathAxisAlignedStepCost = 100; const int PathDiagonalStepCost = 101; namespace { constexpr size_t MaxPathNodes = 1024; using NodeIndexType = uint16_t; using CoordType = uint8_t; using CostType = uint16_t; using PointT = PointOf; struct FrontierNode { PointT position; // Current best guess of the cost of the path to destination // if it goes through this node. CostType f; }; struct ExploredNode { // Preceding node (needed to reconstruct the path at the end). PointT prev; // The current lowest cost from start to this node (0 for the start node). CostType g; }; // A simple map with a fixed number of buckets and static storage. class ExploredNodes { static const size_t NumBuckets = 64; static const size_t BucketCapacity = 3 * MaxPathNodes / NumBuckets; using Entry = std::pair; using Bucket = StaticVector; public: using value_type = Entry; using iterator = value_type *; using const_iterator = const value_type *; [[nodiscard]] const_iterator find(const PointT &point) const { const Bucket &b = bucket(point); const auto it = c_find_if(b, [r = repr(point)](const Entry &e) { return e.first == r; }); if (it == b.end()) return nullptr; return it; } [[nodiscard]] iterator find(const PointT &point) { Bucket &b = bucket(point); auto it = c_find_if(b, [r = repr(point)](const Entry &e) { return e.first == r; }); if (it == b.end()) return nullptr; return it; } // NOLINTNEXTLINE(readability-convert-member-functions-to-static) [[nodiscard]] const_iterator end() const { return nullptr; } // NOLINTNEXTLINE(readability-convert-member-functions-to-static) [[nodiscard]] iterator end() { return nullptr; } void emplace(const PointT &point, const ExploredNode &exploredNode) { bucket(point).emplace_back(repr(point), exploredNode); } [[nodiscard]] bool canInsert(const PointT &point) const { return bucket(point).size() < BucketCapacity; } private: [[nodiscard]] const Bucket &bucket(const PointT &point) const { return buckets_[bucketIndex(point)]; } [[nodiscard]] Bucket &bucket(const PointT &point) { return buckets_[bucketIndex(point)]; } [[nodiscard]] static size_t bucketIndex(const PointT &point) { return ((point.x & 0b111) << 3) | (point.y & 0b111); } [[nodiscard]] static uint16_t repr(const PointT &point) { return (point.x << 8) | point.y; } std::array buckets_; }; bool IsDiagonalStep(const Point &a, const Point &b) { return a.x != b.x && a.y != b.y; } /** * @brief Returns the distance between 2 adjacent nodes. */ CostType GetDistance(PointT startPosition, PointT destinationPosition) { return IsDiagonalStep(startPosition, destinationPosition) ? PathDiagonalStepCost : PathAxisAlignedStepCost; } /** * @brief heuristic, estimated cost from startPosition to destinationPosition. */ CostType GetHeuristicCost(PointT startPosition, PointT destinationPosition) { // This function needs to be admissible, i.e. it should never over-estimate // the distance. // // This calculation assumes we can take diagonal steps until we reach // the same row or column and then take the remaining axis-aligned steps. const int dx = std::abs(static_cast(startPosition.x) - static_cast(destinationPosition.x)); const int dy = std::abs(static_cast(startPosition.y) - static_cast(destinationPosition.y)); const int diagSteps = std::min(dx, dy); // After we've taken `diagSteps`, the remaining steps in one coordinate // will be zero, and in the other coordinate it will be reduced by `diagSteps`. // We then still need to take the remaining steps: // max(dx, dy) - diagSteps = max(dx, dy) - min(dx, dy) = abs(dx - dy) const int axisAlignedSteps = std::abs(dx - dy); return diagSteps * PathDiagonalStepCost + axisAlignedSteps * PathAxisAlignedStepCost; } int ReconstructPath(const ExploredNodes &explored, PointT dest, int8_t *path, size_t maxPathLength) { size_t len = 0; PointT cur = dest; while (true) { const auto it = explored.find(cur); if (it == explored.end()) app_fatal("Failed to reconstruct path"); if (it->second.g == 0) break; // reached start path[len++] = GetPathDirection(it->second.prev, cur); cur = it->second.prev; if (len == maxPathLength) { // Path too long. len = 0; break; } } std::reverse(path, path + len); std::fill(path + len, path + maxPathLength, -1); return len; } } // namespace int8_t GetPathDirection(Point startPosition, Point destinationPosition) { constexpr int8_t PathDirections[9] = { 5, 1, 6, 2, 0, 3, 8, 4, 7 }; return PathDirections[3 * (destinationPosition.y - startPosition.y) + 4 + destinationPosition.x - startPosition.x]; } int FindPath(tl::function_ref canStep, tl::function_ref posOk, Point startPosition, Point destinationPosition, int8_t *path, size_t maxPathLength) { const PointT start { startPosition }; const PointT dest { destinationPosition }; const CostType initialHeuristicCost = GetHeuristicCost(start, dest); if (initialHeuristicCost > PathDiagonalStepCost * maxPathLength) { // Heuristic cost never underestimates the true cost, so we can give up early. return 0; } StaticVector frontier; ExploredNodes explored; { frontier.emplace_back(FrontierNode { .position = start, .f = initialHeuristicCost }); explored.emplace(start, ExploredNode { .prev = {}, .g = 0 }); } const auto frontierComparator = [&explored, &dest](const FrontierNode &a, const FrontierNode &b) { // We use heap functions from which form a max-heap. // We reverse the comparison sign here to get a min-heap. if (a.f != b.f) return a.f > b.f; // For nodes with the same f-score, prefer the ones with lower // heuristic cost (likely to be closer to the goal). const CostType hA = GetHeuristicCost(a.position, dest); const CostType hB = GetHeuristicCost(b.position, dest); if (hA != hB) return hA > hB; // Prefer diagonal steps first. const ExploredNode &aInfo = explored.find(a.position)->second; const ExploredNode &bInfo = explored.find(b.position)->second; const bool isDiagonalA = IsDiagonalStep(aInfo.prev, a.position); const bool isDiagonalB = IsDiagonalStep(bInfo.prev, b.position); if (isDiagonalA != isDiagonalB) return isDiagonalB; // Finally, disambiguate by coordinate: if (a.position.x != b.position.x) return a.position.x > b.position.x; return a.position.y > b.position.y; }; while (!frontier.empty()) { const FrontierNode cur = frontier.front(); // argmin(node.f) for node in openSet if (cur.position == destinationPosition) { return ReconstructPath(explored, cur.position, path, maxPathLength); } std::pop_heap(frontier.begin(), frontier.end(), frontierComparator); frontier.pop_back(); const CostType curG = explored.find(cur.position)->second.g; // Discard invalid nodes. // If this node is already at the maximum number of steps, we can skip processing it. // We don't keep track of the maximum number of steps, so we approximate it. if (curG >= PathDiagonalStepCost * maxPathLength) continue; // When we discover a better path to a node, we push the node to the heap // with the new `f` value even if the node is already in the heap. if (curG + GetHeuristicCost(cur.position, dest) > cur.f) continue; for (const DisplacementOf d : PathDirs) { // We're using `uint8_t` for coordinates. Avoid underflow: if ((cur.position.x == 0 && d.deltaX < 0) || (cur.position.y == 0 && d.deltaY < 0)) continue; const PointT neighborPos = cur.position + d; const bool ok = posOk(neighborPos); if (ok) { if (!canStep(cur.position, neighborPos)) continue; } else { // We allow targeting a non-walkable node if it is the destination. if (neighborPos != dest) continue; } const CostType g = curG + GetDistance(cur.position, neighborPos); if (curG >= PathDiagonalStepCost * maxPathLength) continue; bool improved = false; if (auto it = explored.find(neighborPos); it == explored.end()) { if (explored.canInsert(neighborPos)) { explored.emplace(neighborPos, ExploredNode { .prev = cur.position, .g = g }); improved = true; } } else if (it->second.g > g) { it->second.prev = cur.position; it->second.g = g; improved = true; } if (improved) { const CostType f = g + GetHeuristicCost(neighborPos, dest); if (frontier.size() < MaxPathNodes) { // We always push the node to the heap, even if the same position already exists in it. // When popping from the heap, we discard invalid nodes by checking that `g + h <= f`. frontier.emplace_back(FrontierNode { .position = neighborPos, .f = f }); std::push_heap(frontier.begin(), frontier.end(), frontierComparator); } } } } return 0; // no path } std::optional FindClosestValidPosition(tl::function_ref posOk, Point startingPosition, unsigned int minimumRadius, unsigned int maximumRadius) { return Crawl(minimumRadius, maximumRadius, [&](Displacement displacement) -> std::optional { Point candidatePosition = startingPosition + displacement; if (posOk(candidatePosition)) return candidatePosition; return {}; }); } #ifdef BUILD_TESTING int TestPathGetHeuristicCost(Point startPosition, Point destinationPosition) { return GetHeuristicCost(startPosition, destinationPosition); } #endif } // namespace devilution