From e29a4320009b5de447632ed4a535b5e377a5be38 Mon Sep 17 00:00:00 2001 From: Mathieu Prevel Date: Sun, 3 Dec 2023 20:03:29 +0100 Subject: [PATCH 1/4] Add pathfinder --- indigo/docs/10-information/pathfinding.md | 50 ++++ .../pathfinding/DefaultPathBuilders.scala | 210 ++++++++++++++++ .../indigoextras/pathfinding/PathFinder.scala | 120 +++++++++ .../indigoextras/pathfinding/SearchGrid.scala | 4 + .../pathfinding/PathFinderTests.scala | 227 ++++++++++++++++++ .../pathfinding/PathFindingTests.scala | 3 + 6 files changed, 614 insertions(+) create mode 100644 indigo/docs/10-information/pathfinding.md create mode 100644 indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/DefaultPathBuilders.scala create mode 100644 indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/PathFinder.scala create mode 100644 indigo/indigo-extras/src/test/scala/indigoextras/pathfinding/PathFinderTests.scala diff --git a/indigo/docs/10-information/pathfinding.md b/indigo/docs/10-information/pathfinding.md new file mode 100644 index 000000000..a436a2cb5 --- /dev/null +++ b/indigo/docs/10-information/pathfinding.md @@ -0,0 +1,50 @@ +# 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[List[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[List[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(List(start))` (that is also `Some(List(end))`). + + +### 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.DefaultPathBuilder`. + +- `DefaultPathBuilders.fromAllowedPoints` +- `DefaultPathBuilders.fromImpassablePoints` +- `DefaultPathBuilders.fromWeightedGrid` +- `DefaultPathBuilders.fromWeighted2DGrid` diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/DefaultPathBuilders.scala b/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/DefaultPathBuilders.scala new file mode 100644 index 000000000..8b1938880 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/DefaultPathBuilders.scala @@ -0,0 +1,210 @@ +package indigoextras.pathfinding + +import indigo.* +import indigoextras.pathfinding.DefaultPathBuilders.Movements.* + +import scala.scalajs.js + +// simple path builder implementations based on Point +object DefaultPathBuilders: + + // 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: List[Point] = List(Up, Down) + val Horizontal: List[Point] = List(Right, Left) + val Side: List[Point] = Vertical ++ Horizontal + val Diagonal: List[Point] = List(UpRight, DownRight, DownLeft, UpLeft) + val All: List[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: List[Point], pointsFilter: Point => Boolean): Point => List[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 + */ + @SuppressWarnings(Array("scalafix:DisableSyntax.defaultArgs")) + def fromAllowedPoints( + allowedPoints: Set[Point], + allowedMovements: List[Point] = All, + directSideCost: Int = DefaultSideCost, + diagonalCost: Int = DefaultDiagonalCost, + maxHeuristicFactor: Int = DefaultMaxHeuristicFactor + ): PathBuilder[Point] = + + val buildNeighbours = buildPointNeighbours(allowedMovements, allowedPoints.contains) + + new PathBuilder[Point]: + def neighbours(t: Point): List[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 + + /** 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 + */ + @SuppressWarnings(Array("scalafix:DisableSyntax.defaultArgs")) + def fromImpassablePoints( + impassablePoints: Set[Point], + width: Int, + height: Int, + allowedMovements: List[Point] = All, + directSideCost: Int = DefaultSideCost, + diagonalCost: Int = DefaultDiagonalCost, + maxHeuristicFactor: Int = DefaultMaxHeuristicFactor + ): 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): List[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 + + /** 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 + */ + @SuppressWarnings(Array("scalafix:DisableSyntax.defaultArgs")) + def fromWeighted2DGrid( + grid: js.Array[js.Array[Int]], + width: Int, + height: Int, + allowedMovements: List[Point] = All, + directSideCost: Int = DefaultSideCost, + diagonalCost: Int = DefaultDiagonalCost, + maxHeuristicFactor: Int = DefaultMaxHeuristicFactor + ): 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): List[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 + + /** 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 + */ + @SuppressWarnings(Array("scalafix:DisableSyntax.defaultArgs")) + def fromWeightedGrid( + grid: js.Array[Int], + width: Int, + height: Int, + allowedMovements: List[Point] = All, + directSideCost: Int = DefaultSideCost, + diagonalCost: Int = DefaultDiagonalCost, + maxHeuristicFactor: Int = DefaultMaxHeuristicFactor + ): 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): List[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 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..1686e8802 --- /dev/null +++ b/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/PathFinder.scala @@ -0,0 +1,120 @@ +package indigoextras.pathfinding + +import scala.annotation.tailrec + +/** 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): List[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 + +// A* (A Star) inspired algorithm version allowing generic types and customisation of the path finding +object PathFinder: + + private type OpenList[T] = List[PathProps[T]] // the open list is the list of the points to explore + private type ClosedList[T] = List[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]] = None) 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[List[T]] = { + val startProps = PathProps(start, 0, pathBuilder.heuristic(start, end), pathBuilder.heuristic(start, end)) + val path = loop[T](end, pathBuilder, List(startProps), Nil) + 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]): List[T] = + if (open.isEmpty) Nil + 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]): List[T] = + @tailrec + def loop(props: PathProps[T], acc: List[T]): List[T] = + props.parent match { + case Some(parent) => loop(parent, props.value :: acc) + case None => props.value :: acc + } + + loop(props, Nil) + 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..a9b717612 --- /dev/null +++ b/indigo/indigo-extras/src/test/scala/indigoextras/pathfinding/PathFinderTests.scala @@ -0,0 +1,227 @@ +package indigoextras.pathfinding + +import indigo.* +import indigo.shared.dice.Dice +import indigoextras.pathfinding.DefaultPathBuilders +import indigoextras.pathfinding.DefaultPathBuilders.Movements.* +import indigoextras.pathfinding.PathBuilder +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: List[Point], allowedMoves: List[Point], dice: Dice) { + def allowedPoints: List[Point] = path + + def impassablePoints: List[Point] = + (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: List[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, List(startPoint), startPoint) + TestContext(width, height, path.reverse, allowedMoves, dice) + + @tailrec + private def buildPath(width: Int, height: Int, allowedMoves: List[Point], dice: Dice, path: List[Point], currentPosition: Point): List[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: List[Point], allowedMoves: List[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[List[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(list(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] = DefaultPathBuilders.fromAllowedPoints(context.allowedPoints.toSet, context.allowedMoves) + PathFinder.findPath(start, start, pathBuilder) == Some(List(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 = List(start, end)) + val pathBuilder: PathBuilder[Point] = DefaultPathBuilders.fromAllowedPoints(newContext.allowedPoints.toSet, newContext.allowedMoves) + 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] = DefaultPathBuilders.fromAllowedPoints(context.allowedPoints.toSet, context.allowedMoves) + 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] = DefaultPathBuilders.fromAllowedPoints(context.allowedPoints.toSet, context.allowedMoves) + 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] = DefaultPathBuilders.fromAllowedPoints(context.allowedPoints.toSet, context.allowedMoves) + 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] = DefaultPathBuilders.fromAllowedPoints(context.allowedPoints.toSet, context.allowedMoves) + PathFinder.findPath(start, end, pathBuilder).fold(true)(p => p.distinct.length == p.length) + } + + property("return a list 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] = DefaultPathBuilders.fromAllowedPoints(context.allowedPoints.toSet, context.allowedMoves) + 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] = DefaultPathBuilders.fromImpassablePoints(context.impassablePoints.toSet, context.width, context.height, context.allowedMoves) + 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] = DefaultPathBuilders.fromWeighted2DGrid(context.weighted2DGrid, context.width, context.height, context.allowedMoves) + 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] = DefaultPathBuilders.fromWeighted2DGrid(a2DGrid, context.width, context.height, context.allowedMoves) + val pathBuilder2: PathBuilder[Point] = DefaultPathBuilders.fromWeightedGrid(a2DGrid.flatten.toJSArray, context.width, context.height, context.allowedMoves) + 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] = DefaultPathBuilders.fromWeightedGrid(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: List[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): List[PointWithUserContext] = + context.allowedMoves.map(_ + t.point) + .flatMap(p => 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) DefaultPathBuilders.DefaultSideCost else DefaultPathBuilders.DefaultDiagonalCost + + def heuristic(t1: PointWithUserContext, t2: PointWithUserContext): Int = + (Math.abs(t1.point.x - t2.point.x) + Math.abs(t1.point.y - t2.point.y)) * DefaultPathBuilders.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) From 1eba00a798452983d79c6c792444235c5cb47479 Mon Sep 17 00:00:00 2001 From: Mathieu Prevel Date: Sun, 3 Dec 2023 20:28:50 +0100 Subject: [PATCH 2/4] fmt --- .../pathfinding/DefaultPathBuilders.scala | 262 +++++++------- .../indigoextras/pathfinding/PathFinder.scala | 37 +- .../pathfinding/PathFinderTests.scala | 323 ++++++++++-------- 3 files changed, 332 insertions(+), 290 deletions(-) diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/DefaultPathBuilders.scala b/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/DefaultPathBuilders.scala index 8b1938880..ac52ed404 100644 --- a/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/DefaultPathBuilders.scala +++ b/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/DefaultPathBuilders.scala @@ -11,14 +11,14 @@ object DefaultPathBuilders: // 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 + 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: List[Point] = List(Up, Down) @@ -28,43 +28,44 @@ object DefaultPathBuilders: val All: List[Point] = Side ++ Diagonal // the common default values for A* algorithm - val DefaultSideCost: Int = 10 - val DefaultDiagonalCost: Int = 14 + 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 - */ + /** 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: List[Point], pointsFilter: Point => Boolean): Point => List[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 - */ + * @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 + */ @SuppressWarnings(Array("scalafix:DisableSyntax.defaultArgs")) def fromAllowedPoints( - allowedPoints: Set[Point], - allowedMovements: List[Point] = All, - directSideCost: Int = DefaultSideCost, - diagonalCost: Int = DefaultDiagonalCost, - maxHeuristicFactor: Int = DefaultMaxHeuristicFactor - ): PathBuilder[Point] = + allowedPoints: Set[Point], + allowedMovements: List[Point] = All, + directSideCost: Int = DefaultSideCost, + diagonalCost: Int = DefaultDiagonalCost, + maxHeuristicFactor: Int = DefaultMaxHeuristicFactor + ): PathBuilder[Point] = val buildNeighbours = buildPointNeighbours(allowedMovements, allowedPoints.contains) @@ -78,37 +79,38 @@ object DefaultPathBuilders: (Math.abs(t1.x - t2.x) + Math.abs(t1.y - t2.y)) * maxHeuristicFactor /** 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 - */ + * + * @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 + */ @SuppressWarnings(Array("scalafix:DisableSyntax.defaultArgs")) def fromImpassablePoints( - impassablePoints: Set[Point], - width: Int, - height: Int, - allowedMovements: List[Point] = All, - directSideCost: Int = DefaultSideCost, - diagonalCost: Int = DefaultDiagonalCost, - maxHeuristicFactor: Int = DefaultMaxHeuristicFactor - ): 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) + impassablePoints: Set[Point], + width: Int, + height: Int, + allowedMovements: List[Point] = All, + directSideCost: Int = DefaultSideCost, + diagonalCost: Int = DefaultDiagonalCost, + maxHeuristicFactor: Int = DefaultMaxHeuristicFactor + ): 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): List[Point] = buildNeighbours(t) @@ -119,41 +121,40 @@ object DefaultPathBuilders: def heuristic(t1: Point, t2: Point): Int = (Math.abs(t1.x - t2.x) + Math.abs(t1.y - t2.y)) * maxHeuristicFactor - /** 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 - */ + /** 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 + */ @SuppressWarnings(Array("scalafix:DisableSyntax.defaultArgs")) def fromWeighted2DGrid( - grid: js.Array[js.Array[Int]], - width: Int, - height: Int, - allowedMovements: List[Point] = All, - directSideCost: Int = DefaultSideCost, - diagonalCost: Int = DefaultDiagonalCost, - maxHeuristicFactor: Int = DefaultMaxHeuristicFactor - ): 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) + grid: js.Array[js.Array[Int]], + width: Int, + height: Int, + allowedMovements: List[Point] = All, + directSideCost: Int = DefaultSideCost, + diagonalCost: Int = DefaultDiagonalCost, + maxHeuristicFactor: Int = DefaultMaxHeuristicFactor + ): 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]: @@ -165,40 +166,39 @@ object DefaultPathBuilders: def heuristic(t1: Point, t2: Point): Int = (Math.abs(t1.x - t2.x) + Math.abs(t1.y - t2.y)) * maxHeuristicFactor - /** 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 - */ + /** 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 + */ @SuppressWarnings(Array("scalafix:DisableSyntax.defaultArgs")) def fromWeightedGrid( - grid: js.Array[Int], - width: Int, - height: Int, - allowedMovements: List[Point] = All, - directSideCost: Int = DefaultSideCost, - diagonalCost: Int = DefaultDiagonalCost, - maxHeuristicFactor: Int = DefaultMaxHeuristicFactor - ): 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) + grid: js.Array[Int], + width: Int, + height: Int, + allowedMovements: List[Point] = All, + directSideCost: Int = DefaultSideCost, + diagonalCost: Int = DefaultDiagonalCost, + maxHeuristicFactor: Int = DefaultMaxHeuristicFactor + ): 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): List[Point] = buildNeighbours(t) diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/PathFinder.scala b/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/PathFinder.scala index 1686e8802..4a75ece58 100644 --- a/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/PathFinder.scala +++ b/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/PathFinder.scala @@ -3,14 +3,19 @@ package indigoextras.pathfinding import scala.annotation.tailrec /** The structure allowing to customize the path finding and to build a path of type T - * - * @tparam T - * the type of the points - */ + * + * @tparam T + * the type of the points + */ trait PathBuilder[T]: - def neighbours(t: T): List[T] // neighbours retrieval allows to select the allowed moves (horizontal, vertical, diagonal, impossible moves, jumps, etc.) + def neighbours( + t: T + ): List[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 + def heuristic( + t1: T, + t2: T + ): Int // heuristic allows to select the way to estimate the distance from a point to the end // A* (A Star) inspired algorithm version allowing generic types and customisation of the path finding object PathFinder: @@ -32,7 +37,8 @@ object PathFinder: * @tparam T * the type of the points */ - private case class PathProps[T](value: T, g: Int, h: Int, f: Int, parent: Option[PathProps[T]] = None) derives CanEqual + private case class PathProps[T](value: T, g: Int, h: Int, f: Int, parent: Option[PathProps[T]] = None) + derives CanEqual /** Find a path from start to end using the A* algorithm * @param start @@ -46,14 +52,16 @@ object PathFinder: * @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[List[T]] = { + def findPath[T](start: T, end: T, pathBuilder: PathBuilder[T])(using CanEqual[T, T]): Option[List[T]] = { val startProps = PathProps(start, 0, pathBuilder.heuristic(start, end), pathBuilder.heuristic(start, end)) val path = loop[T](end, pathBuilder, List(startProps), Nil) 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]): List[T] = + private def loop[T](end: T, pathBuilder: PathBuilder[T], open: OpenList[T], closed: ClosedList[T])(using + CanEqual[T, T] + ): List[T] = if (open.isEmpty) Nil else val current = open.minBy(_.f) @@ -77,8 +85,9 @@ object PathFinder: 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) + )(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, @@ -87,9 +96,8 @@ object PathFinder: closed: ClosedList[T], current: PathProps[T], neighbour: T - )(using CanEqual[T,T]): (OpenList[T], ClosedList[T]) = - if (closed.exists(_.value == neighbour)) - (open, closed) + )(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) @@ -117,4 +125,3 @@ object PathFinder: } loop(props, Nil) - diff --git a/indigo/indigo-extras/src/test/scala/indigoextras/pathfinding/PathFinderTests.scala b/indigo/indigo-extras/src/test/scala/indigoextras/pathfinding/PathFinderTests.scala index a9b717612..51a2d74a8 100644 --- a/indigo/indigo-extras/src/test/scala/indigoextras/pathfinding/PathFinderTests.scala +++ b/indigo/indigo-extras/src/test/scala/indigoextras/pathfinding/PathFinderTests.scala @@ -34,20 +34,33 @@ final case class TestContext(width: Int, height: Int, path: List[Point], allowed object TestContext { def build(width: Int, height: Int, allowedMoves: List[Point], dice: Dice): TestContext = - val startX = dice.roll(width) - 1 - val startY = dice.roll(height) - 1 + val startX = dice.roll(width) - 1 + val startY = dice.roll(height) - 1 val startPoint = Point(startX, startY) - val path = buildPath(width, height, allowedMoves, dice, List(startPoint), startPoint) + val path = buildPath(width, height, allowedMoves, dice, List(startPoint), startPoint) TestContext(width, height, path.reverse, allowedMoves, dice) @tailrec - private def buildPath(width: Int, height: Int, allowedMoves: List[Point], dice: Dice, path: List[Point], currentPosition: Point): List[Point] = + private def buildPath( + width: Int, + height: Int, + allowedMoves: List[Point], + dice: Dice, + path: List[Point], + currentPosition: Point + ): List[Point] = computeNextPosition(width, height, path, dice.shuffle(allowedMoves), currentPosition) match { - case None => path + case None => path case Some(p) => buildPath(width, height, allowedMoves, dice, p :: path, p) } - private def computeNextPosition(width: Int, height: Int, path: List[Point], allowedMoves: List[Point], currentPosition: Point): Option[Point] = + private def computeNextPosition( + width: Int, + height: Int, + path: List[Point], + allowedMoves: List[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) @@ -77,151 +90,173 @@ final class PathFinderTests extends Properties("PathFinder") { val genContext: Gen[TestContext] = for { - width <- Gen.choose(8, 64) - height <- Gen.choose(8, 64) + width <- Gen.choose(8, 64) + height <- Gen.choose(8, 64) allowedMoves <- genAllowedMoves - dice <- Gen.choose(1L, Long.MaxValue).map(Dice.fromSeed) + 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) + 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(list(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] = DefaultPathBuilders.fromAllowedPoints(context.allowedPoints.toSet, context.allowedMoves) - PathFinder.findPath(start, start, pathBuilder) == Some(List(start)) + 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(list(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] = + DefaultPathBuilders.fromAllowedPoints(context.allowedPoints.toSet, context.allowedMoves) + PathFinder.findPath(start, start, pathBuilder) == Some(List(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 = List(start, end)) + val pathBuilder: PathBuilder[Point] = + DefaultPathBuilders.fromAllowedPoints(newContext.allowedPoints.toSet, newContext.allowedMoves) + 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] = + DefaultPathBuilders.fromAllowedPoints(context.allowedPoints.toSet, context.allowedMoves) + 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] = + DefaultPathBuilders.fromAllowedPoints(context.allowedPoints.toSet, context.allowedMoves) + 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] = + DefaultPathBuilders.fromAllowedPoints(context.allowedPoints.toSet, context.allowedMoves) + 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] = + DefaultPathBuilders.fromAllowedPoints(context.allowedPoints.toSet, context.allowedMoves) + PathFinder.findPath(start, end, pathBuilder).fold(true)(p => p.distinct.length == p.length) + } + + property("return a list 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] = + DefaultPathBuilders.fromAllowedPoints(context.allowedPoints.toSet, context.allowedMoves) + 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("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 = List(start, end)) - val pathBuilder: PathBuilder[Point] = DefaultPathBuilders.fromAllowedPoints(newContext.allowedPoints.toSet, newContext.allowedMoves) - 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] = DefaultPathBuilders.fromAllowedPoints(context.allowedPoints.toSet, context.allowedMoves) - 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] = DefaultPathBuilders.fromAllowedPoints(context.allowedPoints.toSet, context.allowedMoves) - 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] = DefaultPathBuilders.fromAllowedPoints(context.allowedPoints.toSet, context.allowedMoves) - 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] = DefaultPathBuilders.fromAllowedPoints(context.allowedPoints.toSet, context.allowedMoves) - PathFinder.findPath(start, end, pathBuilder).fold(true)(p => p.distinct.length == p.length) - } - - property("return a list 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] = DefaultPathBuilders.fromAllowedPoints(context.allowedPoints.toSet, context.allowedMoves) - 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] = DefaultPathBuilders.fromImpassablePoints(context.impassablePoints.toSet, context.width, context.height, context.allowedMoves) - 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] = DefaultPathBuilders.fromWeighted2DGrid(context.weighted2DGrid, context.width, context.height, context.allowedMoves) - 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] = DefaultPathBuilders.fromWeighted2DGrid(a2DGrid, context.width, context.height, context.allowedMoves) - val pathBuilder2: PathBuilder[Point] = DefaultPathBuilders.fromWeightedGrid(a2DGrid.flatten.toJSArray, context.width, context.height, context.allowedMoves) - 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] = DefaultPathBuilders.fromWeightedGrid(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: List[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): List[PointWithUserContext] = - context.allowedMoves.map(_ + t.point) - .flatMap(p => 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) DefaultPathBuilders.DefaultSideCost else DefaultPathBuilders.DefaultDiagonalCost - - def heuristic(t1: PointWithUserContext, t2: PointWithUserContext): Int = - (Math.abs(t1.point.x - t2.point.x) + Math.abs(t1.point.y - t2.point.y)) * DefaultPathBuilders.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 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] = DefaultPathBuilders.fromImpassablePoints( + context.impassablePoints.toSet, + context.width, + context.height, + context.allowedMoves + ) + 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] = DefaultPathBuilders.fromWeighted2DGrid( + context.weighted2DGrid, + context.width, + context.height, + context.allowedMoves + ) + 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] = + DefaultPathBuilders.fromWeighted2DGrid(a2DGrid, context.width, context.height, context.allowedMoves) + val pathBuilder2: PathBuilder[Point] = DefaultPathBuilders.fromWeightedGrid( + a2DGrid.flatten.toJSArray, + context.width, + context.height, + context.allowedMoves + ) + 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] = DefaultPathBuilders.fromWeightedGrid(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: List[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): List[PointWithUserContext] = + context.allowedMoves + .map(_ + t.point) + .flatMap(p => 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) DefaultPathBuilders.DefaultSideCost + else DefaultPathBuilders.DefaultDiagonalCost + + def heuristic(t1: PointWithUserContext, t2: PointWithUserContext): Int = + (Math.abs(t1.point.x - t2.point.x) + Math.abs( + t1.point.y - t2.point.y + )) * DefaultPathBuilders.DefaultMaxHeuristicFactor + + PathFinder + .findPath(start, end, pathBuilder) + .fold(false)(path => + path.head == start && path.last == end && path.length >= 1 && path.length <= context.path.length + ) + } } From c42592e9414c44396896c24427a2a7e9a1565d17 Mon Sep 17 00:00:00 2001 From: Mathieu Prevel Date: Thu, 21 Dec 2023 23:31:10 +0100 Subject: [PATCH 3/4] apply review comments --- ...ltPathBuilders.scala => PathBuilder.scala} | 99 ++++++++---- .../indigoextras/pathfinding/PathFinder.scala | 40 ++--- .../pathfinding/PathFinderTests.scala | 149 ++++++++++++------ .../indigo/shared/collections/Batch.scala | 6 + .../main/scala/indigo/shared/dice/Dice.scala | 4 + .../com/example/sandbox/SandboxGame.scala | 4 +- .../com/example/sandbox/SandboxModel.scala | 5 +- .../sandbox/scenes/PathFindingScene.scala | 113 +++++++++++++ 8 files changed, 314 insertions(+), 106 deletions(-) rename indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/{DefaultPathBuilders.scala => PathBuilder.scala} (68%) create mode 100644 indigo/sandbox/src/main/scala/com/example/sandbox/scenes/PathFindingScene.scala diff --git a/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/DefaultPathBuilders.scala b/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/PathBuilder.scala similarity index 68% rename from indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/DefaultPathBuilders.scala rename to indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/PathBuilder.scala index ac52ed404..7e18b2223 100644 --- a/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/DefaultPathBuilders.scala +++ b/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/PathBuilder.scala @@ -1,12 +1,27 @@ package indigoextras.pathfinding import indigo.* -import indigoextras.pathfinding.DefaultPathBuilders.Movements.* +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 DefaultPathBuilders: +object PathBuilder: // defines the movements as relative points object Movements: @@ -21,11 +36,11 @@ object DefaultPathBuilders: val UpLeft: Point = Up + Left // most common movement groups - val Vertical: List[Point] = List(Up, Down) - val Horizontal: List[Point] = List(Right, Left) - val Side: List[Point] = Vertical ++ Horizontal - val Diagonal: List[Point] = List(UpRight, DownRight, DownLeft, UpLeft) - val All: List[Point] = Side ++ Diagonal + 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 @@ -34,6 +49,7 @@ object DefaultPathBuilders: /** 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 @@ -41,10 +57,11 @@ object DefaultPathBuilders: * @return * a function that returns the neighbours of a point */ - def buildPointNeighbours(allowedMovements: List[Point], pointsFilter: Point => Boolean): Point => List[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 @@ -58,19 +75,18 @@ object DefaultPathBuilders: * @return * a path finder builder */ - @SuppressWarnings(Array("scalafix:DisableSyntax.defaultArgs")) def fromAllowedPoints( allowedPoints: Set[Point], - allowedMovements: List[Point] = All, - directSideCost: Int = DefaultSideCost, - diagonalCost: Int = DefaultDiagonalCost, - maxHeuristicFactor: Int = DefaultMaxHeuristicFactor + allowedMovements: Batch[Point], + directSideCost: Int, + diagonalCost: Int, + maxHeuristicFactor: Int ): PathBuilder[Point] = val buildNeighbours = buildPointNeighbours(allowedMovements, allowedPoints.contains) new PathBuilder[Point]: - def neighbours(t: Point): List[Point] = buildNeighbours(t) + 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 @@ -78,6 +94,9 @@ object DefaultPathBuilders: 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 @@ -97,15 +116,14 @@ object DefaultPathBuilders: * @return * a path finder builder */ - @SuppressWarnings(Array("scalafix:DisableSyntax.defaultArgs")) def fromImpassablePoints( impassablePoints: Set[Point], width: Int, height: Int, - allowedMovements: List[Point] = All, - directSideCost: Int = DefaultSideCost, - diagonalCost: Int = DefaultDiagonalCost, - maxHeuristicFactor: Int = DefaultMaxHeuristicFactor + allowedMovements: Batch[Point], + directSideCost: Int, + diagonalCost: Int, + maxHeuristicFactor: Int ): PathBuilder[Point] = val neighboursFilter = (p: Point) => @@ -113,7 +131,7 @@ object DefaultPathBuilders: val buildNeighbours = buildPointNeighbours(allowedMovements, neighboursFilter) new PathBuilder[Point]: - def neighbours(t: Point): List[Point] = buildNeighbours(t) + 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 @@ -121,6 +139,17 @@ object DefaultPathBuilders: 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) * @@ -141,15 +170,14 @@ object DefaultPathBuilders: * @return * a path finder builder */ - @SuppressWarnings(Array("scalafix:DisableSyntax.defaultArgs")) def fromWeighted2DGrid( grid: js.Array[js.Array[Int]], width: Int, height: Int, - allowedMovements: List[Point] = All, - directSideCost: Int = DefaultSideCost, - diagonalCost: Int = DefaultDiagonalCost, - maxHeuristicFactor: Int = DefaultMaxHeuristicFactor + allowedMovements: Batch[Point], + directSideCost: Int, + diagonalCost: Int, + maxHeuristicFactor: Int ): PathBuilder[Point] = val neighboursFilter = (p: Point) => @@ -158,7 +186,7 @@ object DefaultPathBuilders: new PathBuilder[Point]: - def neighbours(t: Point): List[Point] = buildNeighbours(t) + 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) @@ -166,6 +194,9 @@ object DefaultPathBuilders: 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) * @@ -186,25 +217,27 @@ object DefaultPathBuilders: * @return * a path finder builder */ - @SuppressWarnings(Array("scalafix:DisableSyntax.defaultArgs")) def fromWeightedGrid( - grid: js.Array[Int], + grid: Batch[Int], width: Int, height: Int, - allowedMovements: List[Point] = All, - directSideCost: Int = DefaultSideCost, - diagonalCost: Int = DefaultDiagonalCost, - maxHeuristicFactor: Int = DefaultMaxHeuristicFactor + 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): List[Point] = buildNeighbours(t) + 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 index 4a75ece58..3e93de359 100644 --- a/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/PathFinder.scala +++ b/indigo/indigo-extras/src/main/scala/indigoextras/pathfinding/PathFinder.scala @@ -1,27 +1,14 @@ package indigoextras.pathfinding -import scala.annotation.tailrec +import indigo.* -/** 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 - ): List[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 +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] = List[PathProps[T]] // the open list is the list of the points to explore - private type ClosedList[T] = List[PathProps[T]] // the closed list is the list of the points already explored + 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 @@ -37,8 +24,7 @@ object PathFinder: * @tparam T * the type of the points */ - private case class PathProps[T](value: T, g: Int, h: Int, f: Int, parent: Option[PathProps[T]] = None) - derives CanEqual + 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 @@ -52,17 +38,17 @@ object PathFinder: * @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[List[T]] = { - val startProps = PathProps(start, 0, pathBuilder.heuristic(start, end), pathBuilder.heuristic(start, end)) - val path = loop[T](end, pathBuilder, List(startProps), Nil) + 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] - ): List[T] = - if (open.isEmpty) Nil + ): Batch[T] = + if (open.isEmpty) Batch.empty else val current = open.minBy(_.f) if (current.value == end) @@ -116,12 +102,12 @@ object PathFinder: (newOpen, closed) // build the path from the end to the start - private def buildPath[T](props: PathProps[T]): List[T] = + private def buildPath[T](props: PathProps[T]): Batch[T] = @tailrec - def loop(props: PathProps[T], acc: List[T]): List[T] = + 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, Nil) + loop(props, Batch.empty) diff --git a/indigo/indigo-extras/src/test/scala/indigoextras/pathfinding/PathFinderTests.scala b/indigo/indigo-extras/src/test/scala/indigoextras/pathfinding/PathFinderTests.scala index 51a2d74a8..d8fed3675 100644 --- a/indigo/indigo-extras/src/test/scala/indigoextras/pathfinding/PathFinderTests.scala +++ b/indigo/indigo-extras/src/test/scala/indigoextras/pathfinding/PathFinderTests.scala @@ -2,27 +2,29 @@ package indigoextras.pathfinding import indigo.* import indigo.shared.dice.Dice -import indigoextras.pathfinding.DefaultPathBuilders -import indigoextras.pathfinding.DefaultPathBuilders.Movements.* 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._ +import js.JSConverters.* -final case class TestContext(width: Int, height: Int, path: List[Point], allowedMoves: List[Point], dice: Dice) { - def allowedPoints: List[Point] = path +final case class TestContext(width: Int, height: Int, path: Batch[Point], allowedMoves: Batch[Point], dice: Dice) { + def allowedPoints: Batch[Point] = path - def impassablePoints: List[Point] = - (for { + 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 + } yield p).toList) def weighted2DGrid: js.Array[js.Array[Int]] = val grid = Array.fill(height, width)(Int.MaxValue) @@ -33,22 +35,22 @@ final case class TestContext(width: Int, height: Int, path: List[Point], allowed object TestContext { - def build(width: Int, height: Int, allowedMoves: List[Point], dice: Dice): 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, List(startPoint), startPoint) + 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: List[Point], + allowedMoves: Batch[Point], dice: Dice, - path: List[Point], + path: Batch[Point], currentPosition: Point - ): List[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) @@ -57,8 +59,8 @@ object TestContext { private def computeNextPosition( width: Int, height: Int, - path: List[Point], - allowedMoves: List[Point], + path: Batch[Point], + allowedMoves: Batch[Point], currentPosition: Point ): Option[Point] = allowedMoves @@ -79,7 +81,7 @@ 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[List[Point]] = + val genAllowedMoves: Gen[Batch[Point]] = Gen.oneOf( Vertical, Horizontal, @@ -103,20 +105,32 @@ final class PathFinderTests extends Properties("PathFinder") { 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(list(start)) when start and end are the same") = Prop.forAll(genContext) { context => + 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] = - DefaultPathBuilders.fromAllowedPoints(context.allowedPoints.toSet, context.allowedMoves) - PathFinder.findPath(start, start, pathBuilder) == Some(List(start)) + 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 = List(start, end)) + val newContext = context.copy(path = Batch(start, end)) val pathBuilder: PathBuilder[Point] = - DefaultPathBuilders.fromAllowedPoints(newContext.allowedPoints.toSet, newContext.allowedMoves) + PathBuilder.fromAllowedPoints( + newContext.allowedPoints.toSet, + newContext.allowedMoves, + DefaultSideCost, + DefaultDiagonalCost, + DefaultMaxHeuristicFactor + ) PathFinder.findPath(start, end, pathBuilder) == None else true } @@ -125,7 +139,13 @@ final class PathFinderTests extends Properties("PathFinder") { val start: Point = context.dice.shuffle(context.allowedPoints).head val end: Point = context.dice.shuffle(context.allowedPoints).head val pathBuilder: PathBuilder[Point] = - DefaultPathBuilders.fromAllowedPoints(context.allowedPoints.toSet, context.allowedMoves) + PathBuilder.fromAllowedPoints( + context.allowedPoints.toSet, + context.allowedMoves, + DefaultSideCost, + DefaultDiagonalCost, + DefaultMaxHeuristicFactor + ) PathFinder.findPath(start, end, pathBuilder).fold(true)(_.length <= context.path.length) } @@ -133,7 +153,13 @@ final class PathFinderTests extends Properties("PathFinder") { val start: Point = context.dice.shuffle(context.allowedPoints).head val end: Point = context.dice.shuffle(context.allowedPoints).head val pathBuilder: PathBuilder[Point] = - DefaultPathBuilders.fromAllowedPoints(context.allowedPoints.toSet, context.allowedMoves) + PathBuilder.fromAllowedPoints( + context.allowedPoints.toSet, + context.allowedMoves, + DefaultSideCost, + DefaultDiagonalCost, + DefaultMaxHeuristicFactor + ) PathFinder.findPath(start, end, pathBuilder).fold(true)(p => p.forall(context.path.contains)) } @@ -142,7 +168,13 @@ final class PathFinderTests extends Properties("PathFinder") { val end: Point = context.dice.shuffle(context.allowedPoints).head if (start != end) val pathBuilder: PathBuilder[Point] = - DefaultPathBuilders.fromAllowedPoints(context.allowedPoints.toSet, context.allowedMoves) + PathBuilder.fromAllowedPoints( + context.allowedPoints.toSet, + context.allowedMoves, + DefaultSideCost, + DefaultDiagonalCost, + DefaultMaxHeuristicFactor + ) PathFinder.findPath(start, end, pathBuilder).exists(_.length > 1) else true } @@ -151,15 +183,27 @@ final class PathFinderTests extends Properties("PathFinder") { val start: Point = context.dice.shuffle(context.allowedPoints).head val end: Point = context.dice.shuffle(context.allowedPoints).head val pathBuilder: PathBuilder[Point] = - DefaultPathBuilders.fromAllowedPoints(context.allowedPoints.toSet, context.allowedMoves) + 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 list of adjacent entries") = Prop.forAll(genContext) { context => + 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] = - DefaultPathBuilders.fromAllowedPoints(context.allowedPoints.toSet, context.allowedMoves) + 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 } @@ -168,11 +212,14 @@ final class PathFinderTests extends Properties("PathFinder") { 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] = DefaultPathBuilders.fromImpassablePoints( + val pathBuilder: PathBuilder[Point] = PathBuilder.fromImpassablePoints( context.impassablePoints.toSet, context.width, context.height, - context.allowedMoves + context.allowedMoves, + DefaultSideCost, + DefaultDiagonalCost, + DefaultMaxHeuristicFactor ) PathFinder .findPath(start, end, pathBuilder) @@ -184,11 +231,14 @@ final class PathFinderTests extends Properties("PathFinder") { 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] = DefaultPathBuilders.fromWeighted2DGrid( + val pathBuilder: PathBuilder[Point] = PathBuilder.fromWeighted2DGrid( context.weighted2DGrid, context.width, context.height, - context.allowedMoves + context.allowedMoves, + DefaultSideCost, + DefaultDiagonalCost, + DefaultMaxHeuristicFactor ) PathFinder .findPath(start, end, pathBuilder) @@ -202,12 +252,23 @@ final class PathFinderTests extends Properties("PathFinder") { val end: Point = context.dice.shuffle(context.allowedPoints).head val a2DGrid = context.weighted2DGrid val pathBuilder1: PathBuilder[Point] = - DefaultPathBuilders.fromWeighted2DGrid(a2DGrid, context.width, context.height, context.allowedMoves) - val pathBuilder2: PathBuilder[Point] = DefaultPathBuilders.fromWeightedGrid( - a2DGrid.flatten.toJSArray, + 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 + context.allowedMoves, + DefaultSideCost, + DefaultDiagonalCost, + DefaultMaxHeuristicFactor ) PathFinder.findPath(start, end, pathBuilder1) == PathFinder.findPath(start, end, pathBuilder2) } @@ -216,7 +277,7 @@ final class PathFinderTests extends Properties("PathFinder") { // one dimensional grid last element coordinates val start = Point(0, 0) val end = Point(width - 1, height - 1) - val pathBuilder: PathBuilder[Point] = DefaultPathBuilders.fromWeightedGrid(grid, width, height) + val pathBuilder: PathBuilder[Point] = PathBuilder.fromWeightedGrid(Batch(grid), width, height) PathFinder.findPath(start, end, pathBuilder) match { case Some(firstPath) => @@ -232,25 +293,25 @@ final class PathFinderTests extends Properties("PathFinder") { } property("allow to find a path using a custom type") = Prop.forAll(genContext) { context => - val pathWithCustomTypes: List[PointWithUserContext] = context.path.map(PointWithUserContext.fromPoint) - val start: PointWithUserContext = context.dice.shuffle(pathWithCustomTypes).head - val end: PointWithUserContext = context.dice.shuffle(pathWithCustomTypes).head + 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): List[PointWithUserContext] = + def neighbours(t: PointWithUserContext): Batch[PointWithUserContext] = context.allowedMoves .map(_ + t.point) - .flatMap(p => pathWithCustomTypes.find(_.point == p).map(identity)) + .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) DefaultPathBuilders.DefaultSideCost - else DefaultPathBuilders.DefaultDiagonalCost + 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 - )) * DefaultPathBuilders.DefaultMaxHeuristicFactor + )) * PathBuilder.DefaultMaxHeuristicFactor PathFinder .findPath(start, end, pathBuilder) 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) + ) + ) + )): _* + ) + ) + ) From caa7878d791f9a7c518db8e03e20605e714f70ac Mon Sep 17 00:00:00 2001 From: Mathieu Prevel Date: Thu, 21 Dec 2023 23:40:25 +0100 Subject: [PATCH 4/4] update docs --- indigo/docs/10-information/pathfinding.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/indigo/docs/10-information/pathfinding.md b/indigo/docs/10-information/pathfinding.md index a436a2cb5..22302cb42 100644 --- a/indigo/docs/10-information/pathfinding.md +++ b/indigo/docs/10-information/pathfinding.md @@ -13,7 +13,7 @@ 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[List[T]] +// def findPath[T](start: T, end: T, pathBuilder: PathBuilder[T])(using CanEqual[T,T]): Option[Batch[T]] PathFinder.findPath(start, end, pathBuilder) ``` @@ -21,10 +21,11 @@ PathFinder.findPath(start, end, pathBuilder) `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[List[Point]]` containing the path from `start` to `end`. +- 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(List(start))` (that is also `Some(List(end))`). +- 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 @@ -42,9 +43,9 @@ 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.DefaultPathBuilder`. +Indigo provides default path builders, for `Point`, located in `indigoextras.pathfinding.PathBuilder` companion object. -- `DefaultPathBuilders.fromAllowedPoints` -- `DefaultPathBuilders.fromImpassablePoints` -- `DefaultPathBuilders.fromWeightedGrid` -- `DefaultPathBuilders.fromWeighted2DGrid` +- `PathBuilder.fromAllowedPoints` +- `PathBuilder.fromImpassablePoints` +- `PathBuilder.fromWeightedGrid` +- `PathBuilder.fromWeighted2DGrid`