diff --git a/demo/CMakeLists.txt b/demo/CMakeLists.txt index 6bf12b41..721a92b7 100644 --- a/demo/CMakeLists.txt +++ b/demo/CMakeLists.txt @@ -26,7 +26,9 @@ find_package(Yaml-cpp REQUIRED) add_library(${PROJECT_NAME} STATIC src/astar.cpp src/avoid_ghost_behavior.cpp + src/change_dot_cluster_behavior.cpp src/chase_ghost_behavior.cpp + src/cluster.cpp src/entities.cpp src/environment_model.cpp src/random_walk_behavior.cpp diff --git a/demo/include/demo/change_dot_cluster_behavior.hpp b/demo/include/demo/change_dot_cluster_behavior.hpp new file mode 100644 index 00000000..c040514c --- /dev/null +++ b/demo/include/demo/change_dot_cluster_behavior.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include + +#include "environment_model.hpp" +#include "types.hpp" +#include "utils/cluster.hpp" + +namespace demo { + +/** + * @brief The ChangeDotClusterBehavior makes Pacman move towards the closest dot cluster + * that he is not currently inside of. + */ +class ChangeDotClusterBehavior : public arbitration_graphs::Behavior { +public: + using Ptr = std::shared_ptr; + using ConstPtr = std::shared_ptr; + + using Cluster = utils::Cluster; + using Clusters = utils::DotClusterFinder::Clusters; + + explicit ChangeDotClusterBehavior(EnvironmentModel::Ptr environmentModel, + const std::string& name = "ChangeDotClusterBehavior") + : Behavior(name), environmentModel_{std::move(environmentModel)} { + } + + Command getCommand(const Time& /*time*/) override; + + bool checkInvocationCondition(const Time& /*time*/) const override; + bool checkCommitmentCondition(const Time& /*time*/) const override; + + void gainControl(const Time& /*time*/) override { + setTargetCluster(); + } + void loseControl(const Time& /*time*/) override { + targetCluster_.reset(); + } + +private: + void setTargetCluster(); + + std::optional targetCluster_; + EnvironmentModel::Ptr environmentModel_; +}; + +} // namespace demo diff --git a/demo/include/demo/environment_model.hpp b/demo/include/demo/environment_model.hpp index 4bc1a1f1..b6e375cb 100644 --- a/demo/include/demo/environment_model.hpp +++ b/demo/include/demo/environment_model.hpp @@ -6,6 +6,7 @@ #include "types.hpp" #include "utils/astar.hpp" +#include "utils/cluster.hpp" #include "utils/entities.hpp" #include "utils/maze.hpp" @@ -19,6 +20,8 @@ namespace demo { * the world. */ class EnvironmentModel { public: + using Cluster = utils::Cluster; + using Clusters = utils::DotClusterFinder::Clusters; using Entities = utils::Entities; using Maze = utils::Maze; using Ghost = utils::Ghost; @@ -32,7 +35,8 @@ class EnvironmentModel { int distance; }; - explicit EnvironmentModel(const Game& game) : maze_(std::make_shared(game.maze)), astar_(maze_) { + explicit EnvironmentModel(const Game& game) + : maze_{std::make_shared(game.maze)}, astar_{maze_}, clusterFinder_{maze_} { updateEntities(game.reg); }; @@ -42,6 +46,7 @@ class EnvironmentModel { void update(const Game& game) { maze_ = std::make_shared(game.maze); astar_.updateMaze(maze_); + clusterFinder_ = utils::DotClusterFinder{maze_}; updateEntities(game.reg); } @@ -68,6 +73,16 @@ class EnvironmentModel { */ std::optional closestScaredGhost(const Time& time) const; + /** + * @brief Returns a vector of all dot clusters. + * + * A dot cluster is a set of dots (including power pellets) that can be connected by a path passing through neither + * walls nor empty space. + */ + Clusters dotCluster() const { + return clusterFinder_.clusters(); + } + /** * @brief Calculates the Manhattan distance between two positions using A* considering the maze geometry. * @@ -77,6 +92,10 @@ class EnvironmentModel { return astar_.mazeDistance(start, goal); } + std::optional pathTo(const Position& goal) { + return astar_.shortestPath(pacmanPosition(), goal); + } + std::optional pathToClosestDot(const Position& position) const { return astar_.pathToClosestDot(position); } @@ -84,6 +103,9 @@ class EnvironmentModel { bool isWall(const Position& position) const { return maze_->isWall(position); } + Position positionConsideringTunnel(const Position& position) const { + return maze_->positionConsideringTunnel(position); + } protected: void updateEntities(const entt::Registry& registry); @@ -94,6 +116,7 @@ class EnvironmentModel { Maze::ConstPtr maze_; utils::AStar astar_; + utils::DotClusterFinder clusterFinder_; mutable util_caching::Cache closestGhostCache_; mutable util_caching::Cache closestScaredGhostCache_; }; diff --git a/demo/include/demo/pacman_agent.hpp b/demo/include/demo/pacman_agent.hpp index 3265be95..ebd3774c 100644 --- a/demo/include/demo/pacman_agent.hpp +++ b/demo/include/demo/pacman_agent.hpp @@ -3,6 +3,7 @@ #include #include "avoid_ghost_behavior.hpp" +#include "change_dot_cluster_behavior.hpp" #include "chase_ghost_behavior.hpp" #include "eat_closest_dot_behavior.hpp" #include "environment_model.hpp" @@ -34,6 +35,7 @@ class PacmanAgent { environmentModel_ = std::make_shared(game); avoidGhostBehavior_ = std::make_shared(environmentModel_, parameters_.avoidGhostBehavior); + changeDotClusterBehavior_ = std::make_shared(environmentModel_); chaseGhostBehavior_ = std::make_shared(environmentModel_, parameters_.chaseGhostBehavior); eatClosestDotBehavior_ = std::make_shared(environmentModel_); randomWalkBehavior_ = std::make_shared(parameters_.randomWalkBehavior); @@ -42,6 +44,7 @@ class PacmanAgent { rootArbitrator_ = std::make_shared(); rootArbitrator_->addOption(chaseGhostBehavior_, PriorityArbitrator::Option::Flags::INTERRUPTABLE); rootArbitrator_->addOption(avoidGhostBehavior_, PriorityArbitrator::Option::Flags::INTERRUPTABLE); + rootArbitrator_->addOption(changeDotClusterBehavior_, PriorityArbitrator::Option::Flags::INTERRUPTABLE); rootArbitrator_->addOption(eatClosestDotBehavior_, PriorityArbitrator::Option::Flags::INTERRUPTABLE); rootArbitrator_->addOption(randomWalkBehavior_, PriorityArbitrator::Option::Flags::INTERRUPTABLE); rootArbitrator_->addOption(stayInPlaceBehavior_, PriorityArbitrator::Option::Flags::INTERRUPTABLE); @@ -69,6 +72,7 @@ class PacmanAgent { Parameters parameters_; AvoidGhostBehavior::Ptr avoidGhostBehavior_; + ChangeDotClusterBehavior::Ptr changeDotClusterBehavior_; ChaseGhostBehavior::Ptr chaseGhostBehavior_; EatClosestDotBehavior::Ptr eatClosestDotBehavior_; RandomWalkBehavior::Ptr randomWalkBehavior_; diff --git a/demo/include/utils/astar.hpp b/demo/include/utils/astar.hpp index ac69d3ad..a3aa3a79 100644 --- a/demo/include/utils/astar.hpp +++ b/demo/include/utils/astar.hpp @@ -1,40 +1,36 @@ #pragma once #include -#include #include #include #include #include -#include #include -#include #include "demo/types.hpp" #include "utils/maze.hpp" namespace utils { -using Move = demo::Move; using Path = demo::Path; using Position = demo::Position; using TileType = demo::TileType; -struct Cell { +struct AStarCell : public BaseCell { + using Move = demo::Move; + struct CompareCells { - bool operator()(const Cell& left, const Cell& right) { + bool operator()(const AStarCell& left, const AStarCell& right) { return (left.distanceFromStart + left.heuristic) > (right.distanceFromStart + right.heuristic); } }; - Cell(const Position& position, const TileType& type) : position(position), type(type) {}; + AStarCell(const Position& position, const TileType& type) : BaseCell(position, type) {}; - Position position; + bool visited{false}; int distanceFromStart{std::numeric_limits::max()}; double heuristic{std::numeric_limits::max()}; - bool visited{false}; - TileType type; std::optional moveFromPredecessor; double manhattanDistance(const Position& other) const { @@ -42,29 +38,12 @@ struct Cell { } }; -class MazeAdapter { -public: - using MazeStateConstPtr = std::shared_ptr; - - explicit MazeAdapter(Maze::ConstPtr maze) : maze_(std::move(maze)), cells_({maze_->width(), maze_->height()}) {}; - - Cell& cell(const Position& position) const { - if (!cells_[{position.x, position.y}]) { - cells_[{position.x, position.y}] = Cell(position, maze_->at(position)); - } - - return cells_[{position.x, position.y}].value(); - } - -private: - Maze::ConstPtr maze_; - mutable Grid> cells_; -}; - class AStar { public: - using HeuristicFunction = std::function; - using Set = std::priority_queue, Cell::CompareCells>; + using AStarMazeAdapter = MazeAdapter; + using Cell = AStarCell; + using HeuristicFunction = std::function; + using Set = std::priority_queue, AStarCell::CompareCells>; constexpr static int NoPathFound = std::numeric_limits::max(); @@ -81,7 +60,7 @@ class AStar { /** * @brief Returns the shortest path from the start to the goal position considering the maze geometry. */ - Path shortestPath(const Position& start, const Position& goal) const; + std::optional shortestPath(const Position& start, const Position& goal) const; /** * @brief Returns the path from a given start position to the closest dot. @@ -95,7 +74,7 @@ class AStar { } private: - void expandCell(Set& openSet, MazeAdapter& mazeAdapter, const HeuristicFunction& heuristic) const; + void expandCell(Set& openSet, AStarMazeAdapter& mazeAdapter, const HeuristicFunction& heuristic) const; /** * @brief Create a path by traversing predecessor relationships up to a goal position. @@ -103,7 +82,7 @@ class AStar { * Will expand the path backwards until no more predecessor relationship is available. If the cell at the goal * position does not have a predecessor, the path will be empty. */ - Path pathTo(const MazeAdapter& maze, const Position& goal) const; + Path extractPathTo(const AStarMazeAdapter& maze, const Position& goal) const; /** * @brief Approximates the distance of a given cell to a goal while considering a shortcut via the tunnel. @@ -116,12 +95,6 @@ class AStar { return std::min(cell.manhattanDistance(goal), maze_->width() - cell.manhattanDistance(goal)); } - /** - * @brief If we are about to step of the maze and the opposite end is passable as well, - * we assume they are connected by a tunnel and adjust the position accordingly. - */ - Position positionConsideringTunnel(const Position& position) const; - Maze::ConstPtr maze_; mutable util_caching::Cache, int> distanceCache_; }; diff --git a/demo/include/utils/cluster.hpp b/demo/include/utils/cluster.hpp new file mode 100644 index 00000000..c5e5ffbd --- /dev/null +++ b/demo/include/utils/cluster.hpp @@ -0,0 +1,65 @@ +#pragma once + +#include + +#include "demo/types.hpp" +#include "utils/maze.hpp" + +namespace utils { + +using Path = demo::Path; +using Position = demo::Position; +using Positions = demo::Positions; +using TileType = demo::TileType; + +struct ClusterCell : public BaseCell { + ClusterCell(const Position& position, const TileType& type) : BaseCell(position, type) {}; + + bool visited{false}; +}; + +/** + * @brief A cluster is defined by a set of points that can be connected by a path which passes through neither walls nor + * empty space. + */ +struct Cluster { + explicit Cluster(const int& clusterId, const Positions& points) + : id(clusterId), dots(points), center{findClusterCenter()} { + } + bool isInCluster(const Position& target) const { + return std::any_of(dots.begin(), dots.end(), [target](Position dot) { return dot == target; }); + } + + int id; + Positions dots; + + Position center; ///< The dot closest to the average position of all the dots of this cluster + +private: + Position findClusterCenter() const; +}; + +/** + * @brief Search and store all clusters of dots (including power pellets) given the maze state. + */ +class DotClusterFinder { +public: + using Cell = ClusterCell; + using Clusters = std::vector; + using ClusterMazeAdapter = MazeAdapter; + + explicit DotClusterFinder(Maze::ConstPtr maze) : maze_(std::move(maze)), clusters_{findDotClusters()} { + } + Clusters clusters() const { + return clusters_; + } + +private: + Positions expandDot(const Cell& start, const ClusterMazeAdapter& mazeAdapter) const; + Clusters findDotClusters() const; + + Maze::ConstPtr maze_; + Clusters clusters_; +}; + +} // namespace utils diff --git a/demo/include/utils/maze.hpp b/demo/include/utils/maze.hpp index 1d33e91e..29535822 100644 --- a/demo/include/utils/maze.hpp +++ b/demo/include/utils/maze.hpp @@ -1,12 +1,29 @@ #pragma once +#include #include + #include #include "demo/types.hpp" namespace utils { +/** + * @brief Computes the modulus of a given numerator and denominator, ensuring a non-negative result when the denominator + * is positive. + * + * This function calculates the result of the modulus operation. If the denominator is positive, the result is + * non-negative and in the range [0, denominator - 1]. + * + * @param numerator The value to be divided (dividend). + * @param denominator The value by which the numerator is divided (divisor). + * @return An integer representing the modulus of the division. + */ +inline int nonNegativeModulus(const int& numerator, const int& denominator) { + return (denominator + (numerator % denominator)) % denominator; +} + class Maze { public: using MazeState = demo::entt::MazeState; @@ -34,6 +51,14 @@ class Maze { return mazeState_.height(); } + Position positionConsideringTunnel(const Position& position) const { + Position wrappedPosition{nonNegativeModulus(position.x, width()), nonNegativeModulus(position.y, height())}; + if (isPassableCell(wrappedPosition)) { + return wrappedPosition; + } + return position; + } + bool isWall(const Position& position) const { return at(position) == TileType::WALL; } @@ -55,4 +80,48 @@ class Maze { }; }; +struct BaseCell { + using TileType = demo::TileType; + using Position = demo::Position; + + BaseCell(const Position& position, const TileType& type) : position(position), type(type) {}; + + double manhattanDistance(const Position& other) const { + return std::abs(position.x - other.x) + std::abs(position.y - other.y); + } + bool isConsumable() const { + return type == TileType::DOT || type == TileType::ENGERIZER; + } + + Position position; + TileType type; +}; + +/** + * @brief Adapter class for storing properties per cell of the underlying maze. + * + * The MazeAdapter is a helpful little class to put between an algorithm and the actual maze class. It can be used to + * store properties per cell of the maze grid using the templated CellT class. Cells are lazily initialized. + */ +template +class MazeAdapter { +public: + using MazeStateConstPtr = std::shared_ptr; + using Position = demo::Position; + + explicit MazeAdapter(Maze::ConstPtr maze) : maze_(std::move(maze)), cells_({maze_->width(), maze_->height()}) {}; + + CellT& cell(const Position& position) const { + if (!cells_[{position.x, position.y}]) { + cells_[{position.x, position.y}] = CellT(position, maze_->operator[](position)); + } + + return cells_[{position.x, position.y}].value(); + } + +private: + Maze::ConstPtr maze_; + mutable Grid> cells_; +}; + } // namespace utils diff --git a/demo/src/astar.cpp b/demo/src/astar.cpp index a4e1d733..46afa063 100644 --- a/demo/src/astar.cpp +++ b/demo/src/astar.cpp @@ -2,41 +2,26 @@ namespace utils { -/** - * @brief Computes the modulus of a given numerator and denominator, ensuring a non-negative result when the denominator - * is positive. - * - * This function calculates the result of the modulus operation. If the denominator is positive, the result is - * non-negative and in the range [0, denominator - 1]. - * - * @param numerator The value to be divided (dividend). - * @param denominator The value by which the numerator is divided (divisor). - * @return An integer representing the modulus of the division. - */ -int nonNegativeModulus(const int& numerator, const int& denominator) { - return (denominator + (numerator % denominator)) % denominator; -} - int AStar::mazeDistance(const Position& start, const Position& goal) const { if (distanceCache_.cached({start, goal})) { return distanceCache_.cached({start, goal}).value(); } - Path path = shortestPath(start, goal); - int pathLength = static_cast(path.size()); + std::optional path = shortestPath(start, goal); + int pathLength = path ? static_cast(path->size()) : NoPathFound; distanceCache_.cache({start, goal}, pathLength); return pathLength; } -Path AStar::shortestPath(const Position& start, const Position& goal) const { +std::optional AStar::shortestPath(const Position& start, const Position& goal) const { // There is a "virtual" position outside of the maze that entities are on when entering the tunnel. We accept a // small error in the distance computation by neglecting this and wrapping the position to be on either end of the // tunnel. - Position wrappedStart = positionConsideringTunnel(start); - Position wrappedGoal = positionConsideringTunnel(goal); + Position wrappedStart = maze_->positionConsideringTunnel(start); + Position wrappedGoal = maze_->positionConsideringTunnel(goal); - MazeAdapter mazeAdapter(maze_); + AStarMazeAdapter mazeAdapter(maze_); Set openSet; Cell& startCell = mazeAdapter.cell(wrappedStart); @@ -54,21 +39,21 @@ Path AStar::shortestPath(const Position& start, const Position& goal) const { while (!openSet.empty()) { if (openSet.top().position == wrappedGoal) { - return pathTo(mazeAdapter, openSet.top().position); + return extractPathTo(mazeAdapter, openSet.top().position); } expandCell(openSet, mazeAdapter, heuristic); } - return {}; + return std::nullopt; } std::optional AStar::pathToClosestDot(const Position& start) const { // There is a "virtual" position outside of the maze that entities are on when entering the tunnel. We accept a // small error in the distance computation by neglecting this and wrapping the position to be on either end of the // tunnel. - Position wrappedStart = positionConsideringTunnel(start); + Position wrappedStart = maze_->positionConsideringTunnel(start); - MazeAdapter mazeAdapter(maze_); + AStarMazeAdapter mazeAdapter(maze_); Set openSet; Cell& startCell = mazeAdapter.cell(wrappedStart); @@ -85,15 +70,15 @@ std::optional AStar::pathToClosestDot(const Position& start) const { // Unfortunately, the pacman simulation will handle the dot consumption after the move, therefore we need to // explicitly exclude the start position from the search. if (openSet.top().type == TileType::DOT && openSet.top().position != start) { - return pathTo(mazeAdapter, openSet.top().position); + return extractPathTo(mazeAdapter, openSet.top().position); } expandCell(openSet, mazeAdapter, heuristic); } - return {}; + return std::nullopt; } -void AStar::expandCell(Set& openSet, MazeAdapter& mazeAdapter, const HeuristicFunction& heuristic) const { +void AStar::expandCell(Set& openSet, AStarMazeAdapter& mazeAdapter, const HeuristicFunction& heuristic) const { Cell current = openSet.top(); openSet.pop(); @@ -102,7 +87,7 @@ void AStar::expandCell(Set& openSet, MazeAdapter& mazeAdapter, const HeuristicFu for (const auto& move : demo::Move::possibleMoves()) { Position nextPosition = current.position + move.deltaPosition; - nextPosition = positionConsideringTunnel(nextPosition); + nextPosition = maze_->positionConsideringTunnel(nextPosition); if (!maze_->isPassableCell(nextPosition)) { continue; @@ -123,13 +108,13 @@ void AStar::expandCell(Set& openSet, MazeAdapter& mazeAdapter, const HeuristicFu } } -Path AStar::pathTo(const MazeAdapter& maze, const Position& goal) const { +Path AStar::extractPathTo(const AStarMazeAdapter& maze, const Position& goal) const { Path path; Cell current = maze.cell(goal); while (current.moveFromPredecessor) { path.push_back(current.moveFromPredecessor->direction); Position predecessorPosition = current.position - current.moveFromPredecessor->deltaPosition; - predecessorPosition = positionConsideringTunnel(predecessorPosition); + predecessorPosition = maze_->positionConsideringTunnel(predecessorPosition); current = maze.cell(predecessorPosition); } @@ -137,13 +122,4 @@ Path AStar::pathTo(const MazeAdapter& maze, const Position& goal) const { return path; } -Position AStar::positionConsideringTunnel(const Position& position) const { - Position wrappedPosition{nonNegativeModulus(position.x, maze_->width()), - nonNegativeModulus(position.y, maze_->height())}; - if (maze_->isPassableCell(wrappedPosition)) { - return wrappedPosition; - } - return position; -} - } // namespace utils diff --git a/demo/src/avoid_ghost_behavior.cpp b/demo/src/avoid_ghost_behavior.cpp index 067add06..bbf80017 100644 --- a/demo/src/avoid_ghost_behavior.cpp +++ b/demo/src/avoid_ghost_behavior.cpp @@ -10,7 +10,7 @@ Command AvoidGhostBehavior::getCommand(const Time& time) { double maxDistance = -1; for (const auto& move : Move::possibleMoves()) { - auto nextPosition = pacmanPosition + move.deltaPosition; + auto nextPosition = environmentModel_->positionConsideringTunnel(pacmanPosition + move.deltaPosition); if (environmentModel_->isWall(nextPosition)) { continue; diff --git a/demo/src/change_dot_cluster_behavior.cpp b/demo/src/change_dot_cluster_behavior.cpp new file mode 100644 index 00000000..7e30a05c --- /dev/null +++ b/demo/src/change_dot_cluster_behavior.cpp @@ -0,0 +1,50 @@ +#include "demo/change_dot_cluster_behavior.hpp" + +namespace demo { + +Command ChangeDotClusterBehavior::getCommand(const Time& /*time*/) { + std::optional pathToTargetClusterCenter = environmentModel_->pathTo(targetCluster_->center); + + if (!pathToTargetClusterCenter) { + throw std::runtime_error("Failed to compute path to target cluster. Can not provide a sensible command."); + } + + return Command{pathToTargetClusterCenter.value()}; +} + +bool ChangeDotClusterBehavior::checkInvocationCondition(const Time& /*time*/) const { + auto pacmanPosition = environmentModel_->pacmanPosition(); + Clusters clusters = environmentModel_->dotCluster(); + + if (clusters.empty()) { + // We cannot navigate to a cluster if there aren't any + return false; + } + if (clusters.size() == 1 && clusters.front().isInCluster(pacmanPosition)) { + // The only cluster left is the one we are already in + return false; + } + + return true; +} + +bool ChangeDotClusterBehavior::checkCommitmentCondition(const Time& /*time*/) const { + Position pacmanPosition = environmentModel_->pacmanPosition(); + return !targetCluster_->isInCluster(pacmanPosition); +} + +void ChangeDotClusterBehavior::setTargetCluster() { + auto pacmanPosition = environmentModel_->pacmanPosition(); + Clusters clusters = environmentModel_->dotCluster(); + + int minDistance = std::numeric_limits::max(); + for (const auto& cluster : clusters) { + int distance = environmentModel_->mazeDistance(pacmanPosition, cluster.center); + if (distance < minDistance && !cluster.isInCluster(pacmanPosition)) { + minDistance = distance; + targetCluster_ = cluster; + } + } +} + +} // namespace demo diff --git a/demo/src/chase_ghost_behavior.cpp b/demo/src/chase_ghost_behavior.cpp index 2a15e19b..907162ad 100644 --- a/demo/src/chase_ghost_behavior.cpp +++ b/demo/src/chase_ghost_behavior.cpp @@ -15,7 +15,7 @@ Command ChaseGhostBehavior::getCommand(const Time& time) { double minDistance = std::numeric_limits::max(); for (const auto& move : Move::possibleMoves()) { - auto nextPosition = pacmanPosition + move.deltaPosition; + auto nextPosition = environmentModel_->positionConsideringTunnel(pacmanPosition + move.deltaPosition); if (environmentModel_->isWall(nextPosition)) { continue; diff --git a/demo/src/cluster.cpp b/demo/src/cluster.cpp new file mode 100644 index 00000000..b9fbff01 --- /dev/null +++ b/demo/src/cluster.cpp @@ -0,0 +1,87 @@ +#include "utils/cluster.hpp" + +#include +#include + +namespace utils { + +Position Cluster::findClusterCenter() const { + if (dots.empty()) { + throw std::runtime_error("Cannot find center of an empty cluster"); + } + int sumX = 0; + int sumY = 0; + + for (const auto& point : dots) { + sumX += point.x; + sumY += point.y; + } + + const int avgX = std::floor(sumX / dots.size()); + const int avgY = std::floor(sumY / dots.size()); + + const Position avgPosition{avgX, avgY}; + auto distanceComparator = [&avgPosition](const Position& lhs, const Position& rhs) { + return avgPosition.distance(lhs) < avgPosition.distance(rhs); + }; + Position closestDot = *std::min_element(dots.begin(), dots.end(), distanceComparator); + + return closestDot; +} + +DotClusterFinder::Clusters DotClusterFinder::findDotClusters() const { + ClusterMazeAdapter mazeAdapter(maze_); + Clusters clusters; + int clusterId = 0; + + for (int row = 0; row < maze_->height(); row++) { + for (int column = 0; column < maze_->width(); column++) { + Position start{column, row}; + ClusterCell& startCell = mazeAdapter.cell(start); + + if (!startCell.isConsumable() || startCell.visited) { + continue; + } + Positions dots = expandDot(startCell, mazeAdapter); + clusters.emplace_back(clusterId++, dots); + } + } + return clusters; +} + +Positions DotClusterFinder::expandDot(const Cell& start, const ClusterMazeAdapter& mazeAdapter) const { + Positions dots; + + std::queue explorationQueue; + explorationQueue.push(start.position); + + while (!explorationQueue.empty()) { + Position currentPosition = explorationQueue.front(); + explorationQueue.pop(); + ClusterCell& current = mazeAdapter.cell(currentPosition); + + if (current.visited) { + continue; + } + current.visited = true; + dots.push_back(currentPosition); + + for (const auto& move : demo::Move::possibleMoves()) { + Position nextPosition = currentPosition + move.deltaPosition; + nextPosition = maze_->positionConsideringTunnel(nextPosition); + + if (!maze_->isPassableCell(nextPosition)) { + continue; + } + ClusterCell& neighbor = mazeAdapter.cell(nextPosition); + + if (neighbor.isConsumable() && !neighbor.visited) { + explorationQueue.push(nextPosition); + } + } + } + + return dots; +} + +} // namespace utils diff --git a/demo/test/astar.cpp b/demo/test/astar.cpp index f7d3ecca..ecfb6e11 100644 --- a/demo/test/astar.cpp +++ b/demo/test/astar.cpp @@ -6,7 +6,7 @@ #include "mock_environment_model.hpp" -namespace utils { +namespace utils::a_star { using namespace demo; @@ -95,11 +95,12 @@ TEST_F(AStarTest, path) { environmentModel_->setMaze({5, 5}, str); AStar astar(environmentModel_->maze()); - Path path = astar.shortestPath({2, 1}, {3, 3}); + std::optional path = astar.shortestPath({2, 1}, {3, 3}); Path targetPath = {Direction::LEFT, Direction::DOWN, Direction::DOWN, Direction::RIGHT, Direction::RIGHT}; - ASSERT_EQ(path.size(), targetPath.size()); + ASSERT_TRUE(path.has_value()); + ASSERT_EQ(path->size(), targetPath.size()); for (int i = 0; i < targetPath.size(); i++) { - EXPECT_EQ(path.at(i), targetPath.at(i)); + EXPECT_EQ(path->at(i), targetPath.at(i)); } } @@ -112,13 +113,15 @@ TEST_F(AStarTest, pathWithTunnel) { environmentModel_->setMaze({5, 5}, str); AStar astar(environmentModel_->maze()); - Path path = astar.shortestPath({0, 2}, {4, 2}); - ASSERT_EQ(path.size(), 1); - EXPECT_EQ(path.front(), demo::Direction::LEFT); + std::optional path = astar.shortestPath({0, 2}, {4, 2}); + ASSERT_TRUE(path.has_value()); + ASSERT_EQ(path->size(), 1); + EXPECT_EQ(path->front(), demo::Direction::LEFT); path = astar.shortestPath({4, 2}, {0, 2}); - ASSERT_EQ(path.size(), 1); - EXPECT_EQ(path.front(), demo::Direction::RIGHT); + ASSERT_TRUE(path.has_value()); + ASSERT_EQ(path->size(), 1); + EXPECT_EQ(path->front(), demo::Direction::RIGHT); } TEST_F(AStarTest, pathToClosestDot) { @@ -145,4 +148,4 @@ TEST_F(AStarTest, pathToClosestDot) { ASSERT_EQ(path->size(), 3); } -} // namespace utils +} // namespace utils::a_star diff --git a/demo/test/change_dot_cluster_behavior.cpp b/demo/test/change_dot_cluster_behavior.cpp new file mode 100644 index 00000000..8f1308f1 --- /dev/null +++ b/demo/test/change_dot_cluster_behavior.cpp @@ -0,0 +1,132 @@ +#include "demo/change_dot_cluster_behavior.hpp" + +#include + +#include + +#include "mock_environment_model.hpp" + +namespace demo { + +class ChangeDotClusterBehaviorTest : public ::testing::Test { +protected: + ChangeDotClusterBehaviorTest() + : environmentModel_(std::make_shared()), + changeDotClusterBehavior_{environmentModel_} { + setMazeWithTwoClusters(); + environmentModel_->setGhostPositions({1, 1}); + } + + void setMazeWithoutClusters() { + const char str[] = {"#####" + "# #" + "# #" + "# #" + "# #" + "#####"}; + environmentModel_->setMaze({5, 6}, str); + } + void setMazeWithOneCluster() { + const char str[] = {"#####" + "#o..#" + "# #" + "# #" + "# #" + "#####"}; + environmentModel_->setMaze({5, 6}, str); + } + void setMazeWithTwoClusters() { + const char str[] = {"#####" + "#o..#" + "# #" + "# #" + "#.. #" + "#####"}; + environmentModel_->setMaze({5, 6}, str); + } + + MockEnvironmentModel::Ptr environmentModel_; + + ChangeDotClusterBehavior changeDotClusterBehavior_; +}; + +TEST_F(ChangeDotClusterBehaviorTest, checkInvocationConditionTrue) { + Time time = Clock::now(); + + // The invocation condition should be true if... + + // ...we are outside the only cluster that's left + setMazeWithOneCluster(); + environmentModel_->setPacmanPosition({1, 3}); + + ASSERT_TRUE(changeDotClusterBehavior_.checkInvocationCondition(time)); + + // ...or there are multiple clusters left. Doesn't matter if we are outside... + setMazeWithTwoClusters(); + environmentModel_->setPacmanPosition({1, 3}); + ASSERT_TRUE(changeDotClusterBehavior_.checkInvocationCondition(time)); + + // .. or inside a cluster in that case + environmentModel_->setPacmanPosition({1, 1}); + ASSERT_TRUE(changeDotClusterBehavior_.checkInvocationCondition(time)); +} + +TEST_F(ChangeDotClusterBehaviorTest, checkInvocationConditionFalse) { + Time time = Clock::now(); + + // The invocation condition should be false if... + + // ..there are no clusters left + setMazeWithoutClusters(); + environmentModel_->setPacmanPosition({1, 3}); + + ASSERT_FALSE(changeDotClusterBehavior_.checkInvocationCondition(time)); + + // ...we are inside the only cluster that's left + setMazeWithOneCluster(); + environmentModel_->setPacmanPosition({1, 1}); + + ASSERT_FALSE(changeDotClusterBehavior_.checkInvocationCondition(time)); +} + +TEST_F(ChangeDotClusterBehaviorTest, checkCommitmentConditionTrue) { + Time time = Clock::now(); + setMazeWithTwoClusters(); + environmentModel_->setPacmanPosition({1, 2}); + ASSERT_TRUE(changeDotClusterBehavior_.checkInvocationCondition(time)); + + // Once we gained control, we commit to reaching the target dot cluster + changeDotClusterBehavior_.gainControl(time); + ASSERT_TRUE(changeDotClusterBehavior_.checkCommitmentCondition(time)); +} + +TEST_F(ChangeDotClusterBehaviorTest, checkCommitmentConditionFalse) { + Time time = Clock::now(); + setMazeWithTwoClusters(); + environmentModel_->setPacmanPosition({1, 2}); + ASSERT_TRUE(changeDotClusterBehavior_.checkInvocationCondition(time)); + changeDotClusterBehavior_.gainControl(time); + + // Once we reached the target cluster, we finished our intended behavior and give up the commitment + environmentModel_->setPacmanPosition({2, 1}); + ASSERT_FALSE(changeDotClusterBehavior_.checkCommitmentCondition(time)); + + // We reached our goal no matter if we reached the cluster center or just any of the cluster dots + environmentModel_->setPacmanPosition({1, 1}); + ASSERT_FALSE(changeDotClusterBehavior_.checkCommitmentCondition(time)); +} + +TEST_F(ChangeDotClusterBehaviorTest, getCommand) { + Time time = Clock::now(); + setMazeWithTwoClusters(); + environmentModel_->setPacmanPosition({2, 2}); + ASSERT_TRUE(changeDotClusterBehavior_.checkInvocationCondition(time)); + + changeDotClusterBehavior_.gainControl(time); + + // The resulting command should navigate us towards the closest cluster center + Command command = changeDotClusterBehavior_.getCommand(time); + ASSERT_EQ(command.nextDirection(), Direction::UP); +} + +} // namespace demo diff --git a/demo/test/cluster.cpp b/demo/test/cluster.cpp new file mode 100644 index 00000000..72c57c33 --- /dev/null +++ b/demo/test/cluster.cpp @@ -0,0 +1,52 @@ +#include "utils/cluster.hpp" + +#include + +#include "mock_environment_model.hpp" + +namespace utils::a_star { + +using namespace demo; + +struct ExpectedCluster { + int expectedSize; + Position center; + + bool matches(const Cluster& cluster) const { + return cluster.dots.size() == expectedSize && cluster.center == center; + } +}; + +bool clusterExists(const std::vector& clusters, const ExpectedCluster& expectedCluster) { + return std::any_of( + clusters.begin(), clusters.end(), [&](const Cluster& cluster) { return expectedCluster.matches(cluster); }); +} + +class ClusterTest : public ::testing::Test { +protected: + ClusterTest() : environmentModel_(std::make_shared()) { + } + + MockEnvironmentModel::Ptr environmentModel_; +}; + +TEST_F(ClusterTest, dotClusters) { + const char str[] = {"#####" + "#o..#" + " " + "#.. #" + "#####"}; + environmentModel_->setMaze({5, 5}, str); + + DotClusterFinder dotClusterFinder(environmentModel_->maze()); + std::vector clusters = dotClusterFinder.clusters(); + ASSERT_EQ(clusters.size(), 2); + + ExpectedCluster firstExpectedCluster{3, {2, 1}}; + ExpectedCluster secondExpectedCluster{2, {1, 3}}; + + EXPECT_TRUE(clusterExists(clusters, firstExpectedCluster)); + EXPECT_TRUE(clusterExists(clusters, secondExpectedCluster)); +} + +} // namespace utils::a_star diff --git a/demo/test/mock_environment_model.hpp b/demo/test/mock_environment_model.hpp index 95a91c68..032c3631 100644 --- a/demo/test/mock_environment_model.hpp +++ b/demo/test/mock_environment_model.hpp @@ -67,6 +67,7 @@ class MockEnvironmentModel : public EnvironmentModel { void setMaze(const Position& size, const char (&str)[Size]) { maze_ = std::make_shared(makeCustomMazeState({size.x, size.y}, str)); astar_ = utils::AStar(maze_); + clusterFinder_ = utils::DotClusterFinder(maze_); } void setEmptyMaze() { const char str[] = {"##########"