From 730f0e65d529c7e94d79d59fa8246ab3a7ecd022 Mon Sep 17 00:00:00 2001 From: ephphatha Date: Thu, 28 Oct 2021 12:26:47 +1100 Subject: [PATCH] Introduce FindClosestValidPosition function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-calculated crawl table is replaced with partially unrolled loops to handle the special cases covered by the table. Arbitrary limit of 50 placed to allow using this function for searches where vanilla logic would check up to (±49, ±49). --- Source/path.cpp | 139 +++++++++++++++++++++++++++++++++++++++++++++ Source/path.h | 22 +++++++ Source/player.cpp | 17 +++--- test/path_test.cpp | 107 ++++++++++++++++++++++++++++++++++ 4 files changed, 276 insertions(+), 9 deletions(-) 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