diff --git a/indigo/docs/10-information/pathfinding.md b/indigo/docs/10-information/pathfinding.md new file mode 100644 index 000000000..22302cb42 --- /dev/null +++ b/indigo/docs/10-information/pathfinding.md @@ -0,0 +1,51 @@ +# Path finding + +Indigo includes a generic pathfinding algorithm (A* generic variant) as of release `0.15.3`. + +### Quick start + +All of the pathfinding primitives are available with the following import: + +```scala +import indigoextras.pathfinding.* +``` + +The computation of the path can be done with the following function call: + +```scala +// def findPath[T](start: T, end: T, pathBuilder: PathBuilder[T])(using CanEqual[T,T]): Option[Batch[T]] +PathFinder.findPath(start, end, pathBuilder) +``` + +`start` and `end` have the same type and are the start and end points of the path. +`pathBuilder` is the type allowing to customize the pathfinding algorithm (see below). + +If `start` and `end` are of type `Point`: +- when a path is found, the function returns a `Some[Batch[Point]]` containing the path from `start` to `end`. +- when no path is found, the function returns `None` +- when `start` and `end` are the same point, the function returns `Some(Batch(start))` (that is also `Some(Batch(end))`). + +You may also find samples in the tests `indigoextras.pathfinding.PathFinderTests` or in the sandbox `com.example.sandbox.scenes.PathFindingScene`. + +### PathBuilder + +The path builder is a trait that allows to customize the pathfinding algorithm. +It requires the implementations of the 3 main characteristics of the A* algorithm: +- `neighbours`: the function that returns the neighbours of a point +- `distance`: the function that returns the distance (cost) to reach a neighbour from a point +- `heuristic`: the heuristic function used to estimate the distance (cost) from a point to reach the end point + +The path builder also requires a given of type `CanEqual[T,T]` to compare the points. + +## Default path builders + +This object contains default path builders for the most common use cases. +It also contains a few helper functions and constants to compute the neighbours and to define the allowed movements. +If you need to customize the pathfinding algorithm this file is a good starting point. + +Indigo provides default path builders, for `Point`, located in `indigoextras.pathfinding.PathBuilder` companion object. + +- `PathBuilder.fromAllowedPoints` +- `PathBuilder.fromImpassablePoints` +- `PathBuilder.fromWeightedGrid` +- `PathBuilder.fromWeighted2DGrid` diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/PathBuilder.scala b/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/PathBuilder.scala new file mode 100644 index 000000000..7e18b2223 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/PathBuilder.scala @@ -0,0 +1,243 @@ +package indigoextras.pathfinding + +import indigo.* +import indigoextras.pathfinding.PathBuilder.Movements.* + +import scala.scalajs.js + +/** The structure allowing to customize the path finding and to build a path of type T + * + * @tparam T + * the type of the points + */ +trait PathBuilder[T]: + def neighbours( + t: T + ): Batch[T] // neighbours retrieval allows to select the allowed moves (horizontal, vertical, diagonal, impossible moves, jumps, etc.) + def distance(t1: T, t2: T): Int // distance allows to select the cost of each move (diagonal, slow terrain, etc.) + def heuristic( + t1: T, + t2: T + ): Int // heuristic allows to select the way to estimate the distance from a point to the end + +// simple path builder implementations based on Point +object PathBuilder: + + // defines the movements as relative points + object Movements: + // most common movements + val Up: Point = Point(0, -1) + val Down: Point = Point(0, 1) + val Right: Point = Point(1, 0) + val Left: Point = Point(-1, 0) + val UpRight: Point = Up + Right + val DownRight: Point = Down + Right + val DownLeft: Point = Down + Left + val UpLeft: Point = Up + Left + + // most common movement groups + val Vertical: Batch[Point] = Batch(Up, Down) + val Horizontal: Batch[Point] = Batch(Right, Left) + val Side: Batch[Point] = Vertical ++ Horizontal + val Diagonal: Batch[Point] = Batch(UpRight, DownRight, DownLeft, UpLeft) + val All: Batch[Point] = Side ++ Diagonal + + // the common default values for A* algorithm + val DefaultSideCost: Int = 10 + val DefaultDiagonalCost: Int = 14 + val DefaultMaxHeuristicFactor: Int = 10 + + /** Builds a function that returns the neighbours of a point from a list of allowed movements and a filter on the + * generated points + * + * @param allowedMovements + * the allowed movements + * @param pointsFilter + * a filter on the generated points (e.g. to filter impassable points, or points outside the grid) + * @return + * a function that returns the neighbours of a point + */ + def buildPointNeighbours(allowedMovements: Batch[Point], pointsFilter: Point => Boolean): Point => Batch[Point] = + (p: Point) => allowedMovements.map(p + _).filter(pointsFilter) + + /** Builds a path finder builder from a set of allowed points + * + * @param allowedPoints + * the set of allowed points + * @param allowedMovements + * the allowed movements + * @param directSideCost + * the cost of a direct side movement + * @param diagonalCost + * the cost of a diagonal movement + * @param maxHeuristicFactor + * the maximum heuristic factor + * @return + * a path finder builder + */ + def fromAllowedPoints( + allowedPoints: Set[Point], + allowedMovements: Batch[Point], + directSideCost: Int, + diagonalCost: Int, + maxHeuristicFactor: Int + ): PathBuilder[Point] = + + val buildNeighbours = buildPointNeighbours(allowedMovements, allowedPoints.contains) + + new PathBuilder[Point]: + def neighbours(t: Point): Batch[Point] = buildNeighbours(t) + + def distance(t1: Point, t2: Point): Int = + if (t1.x == t2.x || t1.y == t2.y) directSideCost else diagonalCost + + def heuristic(t1: Point, t2: Point): Int = + (Math.abs(t1.x - t2.x) + Math.abs(t1.y - t2.y)) * maxHeuristicFactor + + def fromAllowedPoints(allowedPoints: Set[Point]): PathBuilder[Point] = + fromAllowedPoints(allowedPoints, All, DefaultSideCost, DefaultDiagonalCost, DefaultMaxHeuristicFactor) + + /** Builds a path finder builder from a set of impassable points + * + * @param impassablePoints + * the set of impassable points + * @param width + * the width of the grid + * @param height + * the height of the grid + * @param allowedMovements + * the allowed movements + * @param directSideCost + * the cost of a direct side movement + * @param diagonalCost + * the cost of a diagonal movement + * @param maxHeuristicFactor + * the maximum heuristic factor + * @return + * a path finder builder + */ + def fromImpassablePoints( + impassablePoints: Set[Point], + width: Int, + height: Int, + allowedMovements: Batch[Point], + directSideCost: Int, + diagonalCost: Int, + maxHeuristicFactor: Int + ): PathBuilder[Point] = + + val neighboursFilter = (p: Point) => + p.x >= 0 && p.x < width && p.y >= 0 && p.y < height && !impassablePoints.contains(p) + val buildNeighbours = buildPointNeighbours(allowedMovements, neighboursFilter) + + new PathBuilder[Point]: + def neighbours(t: Point): Batch[Point] = buildNeighbours(t) + + def distance(t1: Point, t2: Point): Int = + if (t1.x == t2.x || t1.y == t2.y) directSideCost else diagonalCost + + def heuristic(t1: Point, t2: Point): Int = + (Math.abs(t1.x - t2.x) + Math.abs(t1.y - t2.y)) * maxHeuristicFactor + + def fromImpassablePoints(impassablePoints: Set[Point], width: Int, height: Int): PathBuilder[Point] = + fromImpassablePoints( + impassablePoints, + width, + height, + All, + DefaultSideCost, + DefaultDiagonalCost, + DefaultMaxHeuristicFactor + ) + + /** Builds a path finder builder from a weighted 2D grid. Impassable points are represented by Int.MaxValue other + * points are represented by their weight/ cost grid(y)(x) is the weight of the point (x, y) + * + * @param grid + * the weighted 2D grid + * @param width + * the width of the grid + * @param height + * the height of the grid + * @param allowedMovements + * the allowed movements + * @param directSideCost + * the cost of a direct side movement + * @param diagonalCost + * the cost of a diagonal movement + * @param maxHeuristicFactor + * the maximum heuristic factor + * @return + * a path finder builder + */ + def fromWeighted2DGrid( + grid: js.Array[js.Array[Int]], + width: Int, + height: Int, + allowedMovements: Batch[Point], + directSideCost: Int, + diagonalCost: Int, + maxHeuristicFactor: Int + ): PathBuilder[Point] = + + val neighboursFilter = (p: Point) => + p.x >= 0 && p.x < width && p.y >= 0 && p.y < height && grid(p.y)(p.x) != Int.MaxValue + val buildNeighbours = buildPointNeighbours(allowedMovements, neighboursFilter) + + new PathBuilder[Point]: + + def neighbours(t: Point): Batch[Point] = buildNeighbours(t) + + def distance(t1: Point, t2: Point): Int = + (if (t1.x == t2.x || t1.y == t2.y) directSideCost else diagonalCost) + grid(t2.y)(t2.x) + + def heuristic(t1: Point, t2: Point): Int = + (Math.abs(t1.x - t2.x) + Math.abs(t1.y - t2.y)) * maxHeuristicFactor + + def fromWeighted2DGrid(grid: js.Array[js.Array[Int]], width: Int, height: Int): PathBuilder[Point] = + fromWeighted2DGrid(grid, width, height, All, DefaultSideCost, DefaultDiagonalCost, DefaultMaxHeuristicFactor) + + /** Builds a path finder builder from a weighted 1D grid. Impassable points are represented by Int.MaxValue other + * points are represented by their weight/ cost grid(y * width + x) is the weight of the point (x, y) + * + * @param grid + * the weighted 1D grid + * @param width + * the width of the grid + * @param height + * the height of the grid + * @param allowedMovements + * the allowed movements + * @param directSideCost + * the cost of a direct side movement + * @param diagonalCost + * the cost of a diagonal movement + * @param maxHeuristicFactor + * the maximum heuristic factor + * @return + * a path finder builder + */ + def fromWeightedGrid( + grid: Batch[Int], + width: Int, + height: Int, + allowedMovements: Batch[Point], + directSideCost: Int, + diagonalCost: Int, + maxHeuristicFactor: Int + ): PathBuilder[Point] = + val neighboursFilter = (p: Point) => + p.x >= 0 && p.x < width && p.y >= 0 && p.y < height && grid(p.y * width + p.x) != Int.MaxValue + val buildNeighbours = buildPointNeighbours(allowedMovements, neighboursFilter) + + new PathBuilder[Point]: + def neighbours(t: Point): Batch[Point] = buildNeighbours(t) + + def distance(t1: Point, t2: Point): Int = + (if (t1.x == t2.x || t1.y == t2.y) directSideCost else diagonalCost) + grid(t2.y * width + t2.x) + + def heuristic(t1: Point, t2: Point): Int = + (Math.abs(t1.x - t2.x) + Math.abs(t1.y - t2.y)) * maxHeuristicFactor + + def fromWeightedGrid(grid: Batch[Int], width: Int, height: Int): PathBuilder[Point] = + fromWeightedGrid(grid, width, height, All, DefaultSideCost, DefaultDiagonalCost, DefaultMaxHeuristicFactor) diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/PathFinder.scala b/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/PathFinder.scala new file mode 100644 index 000000000..3e93de359 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/PathFinder.scala @@ -0,0 +1,113 @@ +package indigoextras.pathfinding + +import indigo.* + +import scala.annotation.tailrec + +// A* (A Star) inspired algorithm version allowing generic types and customisation of the path finding +object PathFinder: + + private type OpenList[T] = Batch[PathProps[T]] // the open list is the list of the points to explore + private type ClosedList[T] = Batch[PathProps[T]] // the closed list is the list of the points already explored + + /** The structure containing the properties of a point + * @param value + * the underlying value (coordinate, point, etc.) + * @param g + * the distance from start + * @param h + * the heuristic (distance from end) + * @param f + * the g + h + * @param parent + * the parent point + * @tparam T + * the type of the points + */ + private case class PathProps[T](value: T, g: Int, h: Int, f: Int, parent: Option[PathProps[T]]) derives CanEqual + + /** Find a path from start to end using the A* algorithm + * @param start + * the start point + * @param end + * the end point + * @param pathBuilder + * the structure allowing to customize the path finding and to build a path of type T + * @tparam T + * the type of the points + * @return + * the path from start to end if it exists + */ + def findPath[T](start: T, end: T, pathBuilder: PathBuilder[T])(using CanEqual[T, T]): Option[Batch[T]] = { + val startProps = PathProps(start, 0, pathBuilder.heuristic(start, end), pathBuilder.heuristic(start, end), None) + val path = loop[T](end, pathBuilder, Batch(startProps), Batch.empty) + if (path.isEmpty && start != end) None else Some(path) + } + + @tailrec + private def loop[T](end: T, pathBuilder: PathBuilder[T], open: OpenList[T], closed: ClosedList[T])(using + CanEqual[T, T] + ): Batch[T] = + if (open.isEmpty) Batch.empty + else + val current = open.minBy(_.f) + if (current.value == end) + // the end is reached, so we can build the path + buildPath(current) + else + // the end is not reached, so we need to continue the search + // we remove the current node from the open list + // we add the current node to the closed list + // we update the lists with the neighbours of the current node + val tmpOpen = open.filterNot(_ == current) + val tmpClosed = current :: closed + val (newOpen, newClosed) = + pathBuilder + .neighbours(current.value) + .foldLeft((tmpOpen, tmpClosed))(updateWithNeighbours(end, pathBuilder, current)) + loop(end, pathBuilder, newOpen, newClosed) + + private def updateWithNeighbours[T]( + end: T, + pathBuilder: PathBuilder[T], + current: PathProps[T] + )(using CanEqual[T, T]): ((OpenList[T], ClosedList[T]), T) => (OpenList[T], ClosedList[T]) = + (openClosed: (OpenList[T], ClosedList[T]), neighbour: T) => + updateWithNeighbours(end, pathBuilder, openClosed._1, openClosed._2, current, neighbour) + + private def updateWithNeighbours[T]( + end: T, + pathBuilder: PathBuilder[T], + open: OpenList[T], + closed: ClosedList[T], + current: PathProps[T], + neighbour: T + )(using CanEqual[T, T]): (OpenList[T], ClosedList[T]) = + if (closed.exists(_.value == neighbour)) (open, closed) + else + val g = current.g + pathBuilder.distance(current.value, neighbour) + val h = pathBuilder.heuristic(neighbour, end) + val f = g + h + val newOpen = + open.find(_.value == neighbour) match { + case Some(oldProps) => // the neighbour is already in the open list + if (f < oldProps.f) // we update the neighbour if the new path is better (according to the heuristic value) + val newProps = PathProps(value = neighbour, g = g, h = h, f = f, parent = Some(current)) + newProps :: open.filterNot(_.value == neighbour) + else open + case None => // the neighbour is not in the open list, so we add it + val newProps = PathProps(value = neighbour, g = g, h = h, f = f, parent = Some(current)) + newProps :: open + } + (newOpen, closed) + + // build the path from the end to the start + private def buildPath[T](props: PathProps[T]): Batch[T] = + @tailrec + def loop(props: PathProps[T], acc: Batch[T]): Batch[T] = + props.parent match { + case Some(parent) => loop(parent, props.value :: acc) + case None => props.value :: acc + } + + loop(props, Batch.empty) diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/SearchGrid.scala b/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/SearchGrid.scala index 121e2fb6c..fd59e5721 100644 --- a/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/SearchGrid.scala +++ b/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/SearchGrid.scala @@ -6,8 +6,11 @@ import indigoextras.pathfinding.GridSquare.EndSquare import indigoextras.pathfinding.GridSquare.ImpassableSquare import indigoextras.pathfinding.GridSquare.StartSquare +import scala.annotation.nowarn import scala.annotation.tailrec +@deprecated("Use the new indigoextras.pathfinding.PathFinder", "0.15.3") +@nowarn("cat=deprecation") final case class SearchGrid( validationWidth: Int, validationHeight: Int, @@ -24,6 +27,7 @@ final case class SearchGrid( } +@nowarn("cat=deprecation") object SearchGrid { def isValid(searchGrid: SearchGrid): Boolean = diff --git a/indigo/indigo-extras/src/test/scala/indigoextras/pathfinding/PathFinderTests.scala b/indigo/indigo-extras/src/test/scala/indigoextras/pathfinding/PathFinderTests.scala new file mode 100644 index 000000000..d8fed3675 --- /dev/null +++ b/indigo/indigo-extras/src/test/scala/indigoextras/pathfinding/PathFinderTests.scala @@ -0,0 +1,323 @@ +package indigoextras.pathfinding + +import indigo.* +import indigo.shared.dice.Dice +import indigoextras.pathfinding.PathBuilder +import indigoextras.pathfinding.PathBuilder.DefaultDiagonalCost +import indigoextras.pathfinding.PathBuilder.DefaultMaxHeuristicFactor +import indigoextras.pathfinding.PathBuilder.DefaultSideCost +import indigoextras.pathfinding.PathBuilder.Movements.* +import indigoextras.pathfinding.PathFinder +import org.scalacheck.* + +import scala.annotation.tailrec +import scala.scalajs.js + +import js.JSConverters.* + +final case class TestContext(width: Int, height: Int, path: Batch[Point], allowedMoves: Batch[Point], dice: Dice) { + def allowedPoints: Batch[Point] = path + + def impassablePoints: Batch[Point] = + Batch.fromSeq((for { + x <- 0 until width + y <- 0 until height + p = Point(x, y) + if !path.contains(p) + } yield p).toList) + + def weighted2DGrid: js.Array[js.Array[Int]] = + val grid = Array.fill(height, width)(Int.MaxValue) + path.foreach(p => grid(p.y)(p.x) = 15) + grid.map(_.toJSArray).toJSArray + +} + +object TestContext { + + def build(width: Int, height: Int, allowedMoves: Batch[Point], dice: Dice): TestContext = + val startX = dice.roll(width) - 1 + val startY = dice.roll(height) - 1 + val startPoint = Point(startX, startY) + val path = buildPath(width, height, allowedMoves, dice, Batch(startPoint), startPoint) + TestContext(width, height, path.reverse, allowedMoves, dice) + + @tailrec + private def buildPath( + width: Int, + height: Int, + allowedMoves: Batch[Point], + dice: Dice, + path: Batch[Point], + currentPosition: Point + ): Batch[Point] = + computeNextPosition(width, height, path, dice.shuffle(allowedMoves), currentPosition) match { + case None => path + case Some(p) => buildPath(width, height, allowedMoves, dice, p :: path, p) + } + + private def computeNextPosition( + width: Int, + height: Int, + path: Batch[Point], + allowedMoves: Batch[Point], + currentPosition: Point + ): Option[Point] = + allowedMoves + .map(_ + currentPosition) + .filterNot(p => path.contains(p) || p.x < 0 || p.x >= width || p.y < 0 || p.y >= height) + .take(1) + .headOption + +} + +// simulate a user defined type to use for the path finder +final case class PointWithUserContext(point: Point, ctx: String) derives CanEqual + +object PointWithUserContext: + def fromPoint(p: Point): PointWithUserContext = PointWithUserContext(p, s"(${p.x},${p.y})") + +final class PathFinderTests extends Properties("PathFinder") { + + private def adjacent(p1: Point, p2: Point): Boolean = Math.abs(p1.x - p2.x) <= 1 && Math.abs(p1.y - p2.y) <= 1 + + val genAllowedMoves: Gen[Batch[Point]] = + Gen.oneOf( + Vertical, + Horizontal, + Side, + Diagonal, + All + ) + + val genContext: Gen[TestContext] = + for { + width <- Gen.choose(8, 64) + height <- Gen.choose(8, 64) + allowedMoves <- genAllowedMoves + dice <- Gen.choose(1L, Long.MaxValue).map(Dice.fromSeed) + } yield TestContext.build(width, height, allowedMoves, dice) + + val weightedGrid: Gen[(Int, Int, Dice, js.Array[Int])] = // width, height, dice, grid + for { + width <- Gen.choose(4, 16) + height <- Gen.choose(4, 16) + dice <- Gen.choose(0L, Long.MaxValue).map(Dice.fromSeed) + } yield (width, height, dice, Array.fill(height * width)(dice.roll(Int.MaxValue) - 1).toJSArray) + + property("return Some(Batch(start)) when start and end are the same") = Prop.forAll(genContext) { context => + val start: Point = context.dice.shuffle(context.allowedPoints).head + val pathBuilder: PathBuilder[Point] = + PathBuilder.fromAllowedPoints( + context.allowedPoints.toSet, + context.allowedMoves, + DefaultSideCost, + DefaultDiagonalCost, + DefaultMaxHeuristicFactor + ) + PathFinder.findPath(start, start, pathBuilder) == Some(Batch(start)) + } + + property("return None when start and end are not connected") = Prop.forAll(genContext) { context => + val start: Point = context.dice.shuffle(context.allowedPoints).head + val end: Point = context.dice.shuffle(context.impassablePoints).head + if (start != end && !context.allowedMoves.map(_ + start).contains(end)) + val newContext = context.copy(path = Batch(start, end)) + val pathBuilder: PathBuilder[Point] = + PathBuilder.fromAllowedPoints( + newContext.allowedPoints.toSet, + newContext.allowedMoves, + DefaultSideCost, + DefaultDiagonalCost, + DefaultMaxHeuristicFactor + ) + PathFinder.findPath(start, end, pathBuilder) == None + else true + } + + property("return a path with a length <= to the generated one") = Prop.forAll(genContext) { context => + val start: Point = context.dice.shuffle(context.allowedPoints).head + val end: Point = context.dice.shuffle(context.allowedPoints).head + val pathBuilder: PathBuilder[Point] = + PathBuilder.fromAllowedPoints( + context.allowedPoints.toSet, + context.allowedMoves, + DefaultSideCost, + DefaultDiagonalCost, + DefaultMaxHeuristicFactor + ) + PathFinder.findPath(start, end, pathBuilder).fold(true)(_.length <= context.path.length) + } + + property("return a path that is a subset of the generated one") = Prop.forAll(genContext) { context => + val start: Point = context.dice.shuffle(context.allowedPoints).head + val end: Point = context.dice.shuffle(context.allowedPoints).head + val pathBuilder: PathBuilder[Point] = + PathBuilder.fromAllowedPoints( + context.allowedPoints.toSet, + context.allowedMoves, + DefaultSideCost, + DefaultDiagonalCost, + DefaultMaxHeuristicFactor + ) + PathFinder.findPath(start, end, pathBuilder).fold(true)(p => p.forall(context.path.contains)) + } + + property("return a path with a length > 1 when start and end are connected") = Prop.forAll(genContext) { context => + val start: Point = context.dice.shuffle(context.allowedPoints).head + val end: Point = context.dice.shuffle(context.allowedPoints).head + if (start != end) + val pathBuilder: PathBuilder[Point] = + PathBuilder.fromAllowedPoints( + context.allowedPoints.toSet, + context.allowedMoves, + DefaultSideCost, + DefaultDiagonalCost, + DefaultMaxHeuristicFactor + ) + PathFinder.findPath(start, end, pathBuilder).exists(_.length > 1) + else true + } + + property("not have duplicated entries") = Prop.forAll(genContext) { context => + val start: Point = context.dice.shuffle(context.allowedPoints).head + val end: Point = context.dice.shuffle(context.allowedPoints).head + val pathBuilder: PathBuilder[Point] = + PathBuilder.fromAllowedPoints( + context.allowedPoints.toSet, + context.allowedMoves, + DefaultSideCost, + DefaultDiagonalCost, + DefaultMaxHeuristicFactor + ) + PathFinder.findPath(start, end, pathBuilder).fold(true)(p => p.distinct.length == p.length) + } + + property("return a batch of adjacent entries") = Prop.forAll(genContext) { context => + val start: Point = context.dice.shuffle(context.allowedPoints).head + val end: Point = context.dice.shuffle(context.allowedPoints).head + val pathBuilder: PathBuilder[Point] = + PathBuilder.fromAllowedPoints( + context.allowedPoints.toSet, + context.allowedMoves, + DefaultSideCost, + DefaultDiagonalCost, + DefaultMaxHeuristicFactor + ) + PathFinder.findPath(start, end, pathBuilder).fold(true) { path => + path.tail.foldLeft((true, path.head))((acc, current) => (acc._1 && adjacent(acc._2, current), current))._1 + } + } + + property("build a path from the impassable points") = Prop.forAll(genContext) { context => + val start: Point = context.dice.shuffle(context.allowedPoints).head + val end: Point = context.dice.shuffle(context.allowedPoints).head + val pathBuilder: PathBuilder[Point] = PathBuilder.fromImpassablePoints( + context.impassablePoints.toSet, + context.width, + context.height, + context.allowedMoves, + DefaultSideCost, + DefaultDiagonalCost, + DefaultMaxHeuristicFactor + ) + PathFinder + .findPath(start, end, pathBuilder) + .fold(false)(path => + path.head == start && path.last == end && path.length >= 1 && path.length <= context.path.length + ) + } + + property("build a path from a weighted 2D grid") = Prop.forAll(genContext) { context => + val start: Point = context.dice.shuffle(context.allowedPoints).head + val end: Point = context.dice.shuffle(context.allowedPoints).head + val pathBuilder: PathBuilder[Point] = PathBuilder.fromWeighted2DGrid( + context.weighted2DGrid, + context.width, + context.height, + context.allowedMoves, + DefaultSideCost, + DefaultDiagonalCost, + DefaultMaxHeuristicFactor + ) + PathFinder + .findPath(start, end, pathBuilder) + .fold(false)(path => + path.head == start && path.last == end && path.length >= 1 && path.length <= context.path.length + ) + } + + property("produce the same result with weighted 1D grid and 2D grid") = Prop.forAll(genContext) { context => + val start: Point = context.dice.shuffle(context.allowedPoints).head + val end: Point = context.dice.shuffle(context.allowedPoints).head + val a2DGrid = context.weighted2DGrid + val pathBuilder1: PathBuilder[Point] = + PathBuilder.fromWeighted2DGrid( + a2DGrid, + context.width, + context.height, + context.allowedMoves, + DefaultSideCost, + DefaultDiagonalCost, + DefaultMaxHeuristicFactor + ) + val pathBuilder2: PathBuilder[Point] = PathBuilder.fromWeightedGrid( + Batch(a2DGrid.flatten), + context.width, + context.height, + context.allowedMoves, + DefaultSideCost, + DefaultDiagonalCost, + DefaultMaxHeuristicFactor + ) + PathFinder.findPath(start, end, pathBuilder1) == PathFinder.findPath(start, end, pathBuilder2) + } + + property("properly handles weighted grid") = Prop.forAll(weightedGrid) { (width, height, dice, grid) => + // one dimensional grid last element coordinates + val start = Point(0, 0) + val end = Point(width - 1, height - 1) + val pathBuilder: PathBuilder[Point] = PathBuilder.fromWeightedGrid(Batch(grid), width, height) + + PathFinder.findPath(start, end, pathBuilder) match { + case Some(firstPath) => + // take a coordinate on the path (but not start or end) and make it impassable + val impassablePoint: Point = dice.shuffle(firstPath.drop(1).dropRight(1)).head + // since the js array is mutable, we can update it and call the previous path builder again + grid(impassablePoint.y * width + impassablePoint.x) = Int.MaxValue + val newPath = PathFinder.findPath(start, end, pathBuilder) + // new path should be different from the first one + newPath.fold(false)(p => p.head == start && p.last == end && p.length >= 1 && !p.contains(impassablePoint)) + case _ => false + } + } + + property("allow to find a path using a custom type") = Prop.forAll(genContext) { context => + val pathWithCustomTypes: Batch[PointWithUserContext] = context.path.map(PointWithUserContext.fromPoint) + val start: PointWithUserContext = context.dice.shuffle(pathWithCustomTypes).head + val end: PointWithUserContext = context.dice.shuffle(pathWithCustomTypes).head + + val pathBuilder: PathBuilder[PointWithUserContext] = + new PathBuilder[PointWithUserContext]: + def neighbours(t: PointWithUserContext): Batch[PointWithUserContext] = + context.allowedMoves + .map(_ + t.point) + .flatMap(p => Batch.fromOption(pathWithCustomTypes.find(_.point == p).map(identity))) + + def distance(t1: PointWithUserContext, t2: PointWithUserContext): Int = + if (t1.point.x == t2.point.x || t1.point.y == t2.point.y) PathBuilder.DefaultSideCost + else PathBuilder.DefaultDiagonalCost + + def heuristic(t1: PointWithUserContext, t2: PointWithUserContext): Int = + (Math.abs(t1.point.x - t2.point.x) + Math.abs( + t1.point.y - t2.point.y + )) * PathBuilder.DefaultMaxHeuristicFactor + + PathFinder + .findPath(start, end, pathBuilder) + .fold(false)(path => + path.head == start && path.last == end && path.length >= 1 && path.length <= context.path.length + ) + } + +} diff --git a/indigo/indigo-extras/src/test/scala/indigoextras/pathfinding/PathFindingTests.scala b/indigo/indigo-extras/src/test/scala/indigoextras/pathfinding/PathFindingTests.scala index 6c858ea11..d1bf4ef16 100644 --- a/indigo/indigo-extras/src/test/scala/indigoextras/pathfinding/PathFindingTests.scala +++ b/indigo/indigo-extras/src/test/scala/indigoextras/pathfinding/PathFindingTests.scala @@ -6,6 +6,9 @@ import indigoextras.pathfinding.GridSquare.EndSquare import indigoextras.pathfinding.GridSquare.ImpassableSquare import indigoextras.pathfinding.GridSquare.StartSquare +import scala.annotation.nowarn + +@nowarn("cat=deprecation") class PathFindingTests extends munit.FunSuite { val coords: Coords = Coords(0, 0) diff --git a/indigo/indigo/src/main/scala/indigo/shared/collections/Batch.scala b/indigo/indigo/src/main/scala/indigo/shared/collections/Batch.scala index 866c20fe3..8fe5f8bc2 100644 --- a/indigo/indigo/src/main/scala/indigo/shared/collections/Batch.scala +++ b/indigo/indigo/src/main/scala/indigo/shared/collections/Batch.scala @@ -148,6 +148,12 @@ sealed trait Batch[+A]: def maxByOption[B](f: A => B)(using ord: Ordering[B]): Option[A] = Option.when(_jsArray.nonEmpty)(_jsArray.maxBy(f)(ord)) + def minBy[B](f: A => B)(using ord: Ordering[B]): A = + _jsArray.minBy(f)(ord) + + def minByOption[B](f: A => B)(using ord: Ordering[B]): Option[A] = + Option.when(_jsArray.nonEmpty)(_jsArray.minBy(f)(ord)) + /** Converts the batch into a String` * @return * `String` diff --git a/indigo/indigo/src/main/scala/indigo/shared/dice/Dice.scala b/indigo/indigo/src/main/scala/indigo/shared/dice/Dice.scala index c4436dcda..3c26a1063 100644 --- a/indigo/indigo/src/main/scala/indigo/shared/dice/Dice.scala +++ b/indigo/indigo/src/main/scala/indigo/shared/dice/Dice.scala @@ -1,5 +1,6 @@ package indigo.shared.dice +import indigo.Batch import indigo.shared.collections.NonEmptyList import indigo.shared.time.Millis import indigo.shared.time.Seconds @@ -66,6 +67,9 @@ trait Dice: */ def shuffle[A](items: List[A]): List[A] + def shuffle[A](items: Batch[A]): Batch[A] = + Batch.fromSeq(shuffle(items.toList)) + override def toString: String = s"Dice(seed = ${seed.toString()})" diff --git a/indigo/sandbox/src/main/scala/com/example/sandbox/SandboxGame.scala b/indigo/sandbox/src/main/scala/com/example/sandbox/SandboxGame.scala index 4623dfb79..c66b3ca25 100644 --- a/indigo/sandbox/src/main/scala/com/example/sandbox/SandboxGame.scala +++ b/indigo/sandbox/src/main/scala/com/example/sandbox/SandboxGame.scala @@ -15,6 +15,7 @@ import com.example.sandbox.scenes.LineReflectionScene import com.example.sandbox.scenes.ManyEventHandlers import com.example.sandbox.scenes.MutantsScene import com.example.sandbox.scenes.OriginalScene +import com.example.sandbox.scenes.PathFindingScene import com.example.sandbox.scenes.PointersScene import com.example.sandbox.scenes.RefractionScene import com.example.sandbox.scenes.Shaders @@ -73,7 +74,8 @@ object SandboxGame extends IndigoGame[SandboxBootData, SandboxStartupData, Sandb PointersScene, BoundingCircleScene, LineReflectionScene, - CameraWithCloneTilesScene + CameraWithCloneTilesScene, + PathFindingScene ) val eventFilters: EventFilters = EventFilters.Permissive diff --git a/indigo/sandbox/src/main/scala/com/example/sandbox/SandboxModel.scala b/indigo/sandbox/src/main/scala/com/example/sandbox/SandboxModel.scala index 00773dd6a..1dc8d49d5 100644 --- a/indigo/sandbox/src/main/scala/com/example/sandbox/SandboxModel.scala +++ b/indigo/sandbox/src/main/scala/com/example/sandbox/SandboxModel.scala @@ -1,8 +1,9 @@ package com.example.sandbox import com.example.sandbox.scenes.ConfettiModel +import com.example.sandbox.scenes.PathFindingModel import com.example.sandbox.scenes.PointersModel -import indigo._ +import indigo.* import indigoextras.ui.InputFieldChange object SandboxModel { @@ -16,6 +17,7 @@ object SandboxModel { None, ConfettiModel.empty, PointersModel.empty, + PathFindingModel.empty, Radians.zero ) @@ -138,6 +140,7 @@ final case class SandboxGameModel( data: Option[String], confetti: ConfettiModel, pointers: PointersModel, + pathfinding: PathFindingModel, rotation: Radians ) diff --git a/indigo/sandbox/src/main/scala/com/example/sandbox/scenes/PathFindingScene.scala b/indigo/sandbox/src/main/scala/com/example/sandbox/scenes/PathFindingScene.scala new file mode 100644 index 000000000..fbe435402 --- /dev/null +++ b/indigo/sandbox/src/main/scala/com/example/sandbox/scenes/PathFindingScene.scala @@ -0,0 +1,113 @@ +package com.example.sandbox.scenes + +import com.example.sandbox.* +import indigo.* +import indigo.scenes.* +import indigo.scenes.* +import indigo.syntax.* +import indigoextras.pathfinding.PathBuilder.Movements.* +import indigoextras.pathfinding.PathBuilder.* +import indigoextras.pathfinding.* + +import scala.scalajs.js +import scala.scalajs.js.JSConverters.* + +final case class PathFindingModel( + // will represent a weighted grid + data: js.Array[Int] = js.Array() +) + +object PathFindingModel: + val empty: PathFindingModel = PathFindingModel() + +object PathFindingScene extends Scene[SandboxStartupData, SandboxGameModel, SandboxViewModel]: + + type SceneModel = PathFindingModel + type SceneViewModel = SandboxViewModel + + val increase: Int = 30 + val gridSize: Int = 10 + val gridDisplaySize: Int = 20 + + def eventFilters: EventFilters = + EventFilters.Restricted + + def modelLens: Lens[SandboxGameModel, SceneModel] = + Lens(_.pathfinding, (m, pm) => m.copy(pathfinding = pm)) + + def viewModelLens: Lens[SandboxViewModel, SandboxViewModel] = + Lens.keepOriginal + + def name: SceneName = + SceneName("pathfinding") + + def subSystems: Set[SubSystem] = + Set() + + def updateModel( + context: SceneContext[SandboxStartupData], + model: SceneModel + ): GlobalEvent => Outcome[SceneModel] = + + case FrameTick => + if (model.data.isEmpty) + Outcome( + PathFindingModel(data = List.fill(gridSize * gridSize)(0).toJSArray) // initialise the grid with 0s + ) + else + // increase randomly the value at a random point + val n = context.dice.roll(model.data.length) - 1 + val v = (model.data(n) + context.dice.roll(increase)) % 256 + model.data.update(n, v) + Outcome(model) + + case _ => Outcome(model) + + def updateViewModel( + context: SceneContext[SandboxStartupData], + model: SceneModel, + viewModel: SandboxViewModel + ): GlobalEvent => Outcome[SandboxViewModel] = + _ => Outcome(viewModel) + + def present( + context: SceneContext[SandboxStartupData], + model: SceneModel, + viewModel: SandboxViewModel + ): Outcome[SceneUpdateFragment] = + + // in practice we should not have to compute the path every frame + val start = Point(0, 0) // top left + val end = Point(gridSize - 1, gridSize - 1) // bottom right + val pathBuilder = + PathBuilder.fromWeightedGrid( + grid = Batch(model.data), + width = gridSize, + height = gridSize, + allowedMovements = All, + directSideCost = DefaultSideCost, + diagonalCost = DefaultDiagonalCost, + maxHeuristicFactor = DefaultMaxHeuristicFactor + ) + val path = PathFinder.findPath(start, end, pathBuilder).getOrElse(Batch.empty) // if no path found, return empty + + Outcome( + SceneUpdateFragment( + Batch.combineAll( + (for { + y <- 0 until gridSize + x <- 0 until gridSize + c = model.data(y * gridSize + x) + } yield Batch( + Shape.Box( + Rectangle(Point(x * gridDisplaySize, y * gridDisplaySize), Size(gridDisplaySize, gridDisplaySize)), + Fill.Color( + // if the point is in the path, color it red, otherwise color it with the value of the grid as a shade of grey + if (path.contains(Point(x, y))) RGBA.Red + else RGBA.fromColorInts(c, c, c) + ) + ) + )): _* + ) + ) + )