diff --git a/Source/path.cpp b/Source/path.cpp index bf950e621..a0eff10cb 100644 --- a/Source/path.cpp +++ b/Source/path.cpp @@ -5,6 +5,8 @@ */ #include "path.h" +#include + #include "gendung.h" #include "objects.h" @@ -384,6 +386,143 @@ bool path_solid_pieces(Point startPosition, Point destinationPosition) return rv; } +std::optional FindClosestValidPosition(const std::function &posOk, Point startingPosition, unsigned int minimumRadius, unsigned int maximumRadius) +{ + if (minimumRadius > maximumRadius) { + return {}; // No valid search space with the given params. + } + + if (minimumRadius == 0U) { + if (posOk(startingPosition)) { + return startingPosition; + } + } + + if (minimumRadius <= 1U && maximumRadius >= 1U) { + // unrolling the case for radius 1 to save having to guard the corner check in the loop below. + + Point candidatePosition = startingPosition + Direction::SouthWest; + if (posOk(candidatePosition)) { + return candidatePosition; + } + candidatePosition = startingPosition + Direction::NorthEast; + if (posOk(candidatePosition)) { + return candidatePosition; + } + + candidatePosition = startingPosition + Direction::NorthWest; + if (posOk(candidatePosition)) { + return candidatePosition; + } + + candidatePosition = startingPosition + Direction::SouthEast; + if (posOk(candidatePosition)) { + return candidatePosition; + } + } + + if (maximumRadius >= 2U) { + for (int i = static_cast(std::max(minimumRadius, 2U)); i <= static_cast(std::min(maximumRadius, 50U)); i++) { + int x = 0; + int y = i; + + // special case the checks when x == 0 to save checking the same tiles twice + Point candidatePosition = startingPosition + Displacement { x, y }; + if (posOk(candidatePosition)) { + return candidatePosition; + } + candidatePosition = startingPosition + Displacement { x, -y }; + if (posOk(candidatePosition)) { + return candidatePosition; + } + + while (x < i - 1) { + x++; + + candidatePosition = startingPosition + Displacement { -x, y }; + if (posOk(candidatePosition)) { + return candidatePosition; + } + + candidatePosition = startingPosition + Displacement { x, y }; + if (posOk(candidatePosition)) { + return candidatePosition; + } + + candidatePosition = startingPosition + Displacement { -x, -y }; + if (posOk(candidatePosition)) { + return candidatePosition; + } + + candidatePosition = startingPosition + Displacement { x, -y }; + if (posOk(candidatePosition)) { + return candidatePosition; + } + } + + // special case for inset corners + y--; + candidatePosition = startingPosition + Displacement { -x, y }; + if (posOk(candidatePosition)) { + return candidatePosition; + } + + candidatePosition = startingPosition + Displacement { x, y }; + if (posOk(candidatePosition)) { + return candidatePosition; + } + + candidatePosition = startingPosition + Displacement { -x, -y }; + if (posOk(candidatePosition)) { + return candidatePosition; + } + + candidatePosition = startingPosition + Displacement { x, -y }; + if (posOk(candidatePosition)) { + return candidatePosition; + } + x++; + + while (y > 0) { + candidatePosition = startingPosition + Displacement { -x, y }; + if (posOk(candidatePosition)) { + return candidatePosition; + } + + candidatePosition = startingPosition + Displacement { x, y }; + if (posOk(candidatePosition)) { + return candidatePosition; + } + + candidatePosition = startingPosition + Displacement { -x, -y }; + if (posOk(candidatePosition)) { + return candidatePosition; + } + + candidatePosition = startingPosition + Displacement { x, -y }; + if (posOk(candidatePosition)) { + return candidatePosition; + } + + y--; + } + + // as above, special case for y == 0 + candidatePosition = startingPosition + Displacement { -x, y }; + if (posOk(candidatePosition)) { + return candidatePosition; + } + + candidatePosition = startingPosition + Displacement { x, y }; + if (posOk(candidatePosition)) { + return candidatePosition; + } + } + } + + return {}; +} + #ifdef RUN_TESTS int TestPathGetHeuristicCost(Point startPosition, Point destinationPosition) { diff --git a/Source/path.h b/Source/path.h index 71711f3d2..9ccd806e5 100644 --- a/Source/path.h +++ b/Source/path.h @@ -11,6 +11,7 @@ #include "engine/direction.hpp" #include "engine/point.hpp" +#include "utils/stdcompat/optional.hpp" namespace devilution { @@ -66,4 +67,25 @@ const Displacement PathDirs[8] = { // clang-format on }; +/** + * @brief Searches for the closest position that passes the check in expanding "rings". + * + * The search space is roughly equivalent to a square of tiles where the walking distance is equal to the radius except + * the corners are "rounded" (inset) by one tile. For example the following is a search space of radius 4: + * _XXXXXXX_ + * XX_____XX + * X_______X + * < snip > + * X_______X + * XX_____XX + * _XXXXXXX_ + * + * @param posOk Used to check if a position is valid + * @param startingPosition dungeon tile location to start the search from + * @param minimumRadius A value from 0 to 50, allows skipping nearby tiles (e.g. specify radius 1 to skip checking the starting tile) + * @param maximumRadius The maximum distance to check, defaults to 18 for vanilla compatibility but supports values up to 50 + * @return either the closest valid point or an empty optional + */ +std::optional FindClosestValidPosition(const std::function &posOk, Point startingPosition, unsigned int minimumRadius = 0, unsigned int maximumRadius = 18); + } // namespace devilution diff --git a/Source/player.cpp b/Source/player.cpp index 288e4337d..579a7800b 100644 --- a/Source/player.cpp +++ b/Source/player.cpp @@ -3591,16 +3591,15 @@ void SyncInitPlrPos(int pnum) return position; } - for (int k : CrawlNum) { - int ck = k + 2; - for (auto j = static_cast(CrawlTable[k]); j > 0; j--, ck += 2) { - Point position = player.position.tile + Displacement { CrawlTable[ck - 1], CrawlTable[ck] }; - if (PosOkPlayer(player, position) && !PosOkPortal(currlevel, position.x, position.y)) - return position; - } - } + std::optional nearPosition = FindClosestValidPosition( + [&player](Point testPosition) { + return PosOkPlayer(player, testPosition) && !PosOkPortal(currlevel, testPosition.x, testPosition.y); + }, + player.position.tile, + 1, // skip the starting tile since that was checked in the previous loop + 50); - return Point { 0, 0 }; + return nearPosition.value_or(Point { 0, 0 }); }(); player.position.tile = position; diff --git a/test/path_test.cpp b/test/path_test.cpp index 98b229fb4..eccf46935 100644 --- a/test/path_test.cpp +++ b/test/path_test.cpp @@ -165,4 +165,111 @@ TEST(PathTest, Walkable) EXPECT_FALSE(IsTileWalkable({ 5, 5 })) << "Solid tiles occupied by an open door remain unwalkable"; EXPECT_TRUE(IsTileWalkable({ 5, 5 }, true)) << "Solid tiles occupied by an open door become walkable when ignoring doors"; } + +TEST(PathTest, FindClosest) +{ + { + std::array, 101> searchedTiles {}; + + std::optional nearPosition = FindClosestValidPosition( + [&searchedTiles](Point testPosition) { + searchedTiles[testPosition.x][testPosition.y]++; + return false; + }, + { 50, 50 }, 0, 50); + + EXPECT_FALSE(nearPosition) << "Searching with no valid tiles should return an empty optional"; + + for (int x = 0; x < searchedTiles.size(); x++) { + for (int y = 0; y < searchedTiles[x].size(); y++) { + if (IsAnyOf(x, 0, 100) && IsAnyOf(y, 0, 100)) { + EXPECT_EQ(searchedTiles[x][y], 0) << "Extreme corners should be skipped due to the inset/rounded search space"; + } else { + EXPECT_EQ(searchedTiles[x][y], 1) << "Position " << Point { x, y } << " should have been searched exactly once"; + } + } + } + } + { + std::array, 5> searchedTiles {}; + + std::optional nearPosition = FindClosestValidPosition( + [&searchedTiles](Point testPosition) { + searchedTiles[testPosition.x][testPosition.y]++; + return false; + }, + { 2, 2 }, 1, 2); + + EXPECT_FALSE(nearPosition) << "Still shouldn't find a valid position with no valid tiles"; + + for (int x = 0; x < searchedTiles.size(); x++) { + for (int y = 0; y < searchedTiles[x].size(); y++) { + if (Point { x, y } == Point { 2, 2 }) { + EXPECT_EQ(searchedTiles[x][y], 0) << "The starting tile should be skipped with a min radius of 1"; + } else if (IsAnyOf(x, 0, 4) && IsAnyOf(y, 0, 4)) { + EXPECT_EQ(searchedTiles[x][y], 0) << "Corners should be skipped"; + } else { + EXPECT_EQ(searchedTiles[x][y], 1) << "All tiles in range should be searched exactly once"; + } + } + } + } + { + std::array, 3> searchedTiles {}; + + std::optional nearPosition = FindClosestValidPosition( + [&searchedTiles](Point testPosition) { + searchedTiles[testPosition.x][testPosition.y]++; + return false; + }, + { 1, 1 }, 0, 0); + + EXPECT_FALSE(nearPosition) << "Searching with no valid tiles should return an empty optional"; + + for (int x = 0; x < searchedTiles.size(); x++) { + for (int y = 0; y < searchedTiles[x].size(); y++) { + if (Point { x, y } == Point { 1, 1 }) { + EXPECT_EQ(searchedTiles[x][y], 1) << "Only the starting tile should be searched with max radius 0"; + } else { + EXPECT_EQ(searchedTiles[x][y], 0) << "Position " << Point { x, y } << " should not have been searched"; + } + } + } + } + + { + std::array, 7> searchedTiles {}; + + std::optional nearPosition = FindClosestValidPosition( + [&searchedTiles](Point testPosition) { + searchedTiles[testPosition.x][testPosition.y]++; + return false; + }, + { 3, 3 }, 3, 3); + + EXPECT_FALSE(nearPosition) << "Searching with no valid tiles should return an empty optional"; + + for (int x = 0; x < searchedTiles.size(); x++) { + for (int y = 0; y < searchedTiles[x].size(); y++) { + if ((IsAnyOf(x, 1, 5) && IsAnyOf(y, 1, 5)) // inset corners + || (IsAnyOf(x, 0, 6) && IsNoneOf(y, 0, 6)) // left/right sides + || (IsNoneOf(x, 0, 6) && IsAnyOf(y, 0, 6)) // top/bottom sides + ) { + EXPECT_EQ(searchedTiles[x][y], 1) << "Searching with a fixed radius should make a square with inset corners"; + } else { + EXPECT_EQ(searchedTiles[x][y], 0) << "Position " << Point { x, y } << " should not have been searched"; + } + } + } + } + { + std::optional nearPosition = FindClosestValidPosition( + [](Point testPosition) { + return true; + }, + { 50, 50 }, 21, 50); + + EXPECT_EQ(*nearPosition, (Point { 50, 50 } + Displacement { 0, 21 })) << "First candidate position with a minimum radius should be at {0, +y}"; + } +} } // namespace devilution