Skip to content

Add WaypointPath trait #865

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Apr 9, 2025
Merged

Conversation

JPonte
Copy link
Contributor

@JPonte JPonte commented Mar 16, 2025

Issue #804

Simply create a path like:

val path = WaypointPath(
    waypoints = List(Vector2(20, 20), Vector2(80, 30), Vector2(120, 100)),
    radius = 0.0,
    loop = true
)

and in either the updateModel or present methods one can get the expected position with:

path.calculatePosition(at = 0.5)

where at is the relative position in the path. So 0.0 is the beginning of the path and 1.0 is the end. If looping is enabled values above 1.0 wrap around and values lower than 0.0 also wrap around but in reverse.

It can be easily integrated in timelines like:

def loopedPathSignalFunction(waypoints: Vector2*): SignalFunction[Double, (Vector2, Radians)] =
  val path = WaypointPath(waypoints.toList, 0.0, true)
  SignalFunction(over => path.calculatePosition(over))

val traverseSimplePath = loopedPathSignalFunction(Vector2(0, 0), Vector2(1, 0), Vector2(1, 1))

val tl: Timeline[Graphic[Material.ImageEffects]] =
  timeline(
    layer(
      animate(2.seconds) { g =>
        lerp >>> traverseSimplePath >>> SignalFunction((v, r) => g.moveTo(v.toPoint).rotateTo(r))
      }
    )
  )

Comment on lines 91 to 113
private def waypointsWithRadius(
waypoints: List[Vector2],
radius: Double,
loop: Boolean
): List[Vector2] =
val list =
if loop then
waypoints
.appended(waypoints.head)
else waypoints

val distances = list
.foldLeft(List.empty[Vector2]):
case (acc, v2) =>
acc.lastOption match
case Some(v1) =>
val fullDistance = v1.distanceTo(v2)
val cappedDistance = (fullDistance - radius).max(0.0)
val newV2 = lerpVector2(v1, v2, cappedDistance / fullDistance)
acc :+ newV2
case None => List(v2)
if loop then distances.tail.dropRight(1).prepended(distances.last)
else distances
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure how useful this is. The idea is that if the waypoints aren't precise locations but general positions and when a character with a large sprite gets close enough to it, it considers it visited.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know how this feels in practice, but generally I think that's a very sensible approach. Waiting for things to land perfectly something is a headache and usually not worth the hassle!

Might be worth making this configurable though? How close is close enough? You have a few 'config' options already, if you add any more, consider creating a config object:

WaypointPathConfig(pathRadius: Radians, loop: Boolean, requiredProximity: Vector2)
object WaypointPathConfig:
  val initial: WaypointPathConfig = WaypointPathConfig(Radians.zero, false, Vector2.one)

requiredProximity is probably a poor name, it could also be a function that lets the user supply a proximity test - but that's proably overkill for the first cut.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added an example of it in the sandbox that hopefully illustrates how it works.

@JPonte JPonte marked this pull request as ready for review March 20, 2025 20:55
@JPonte
Copy link
Contributor Author

JPonte commented Mar 20, 2025

@davesmith00000 not sure this is what you had in mind but added a sandbox example of it working with timelines.

Copy link
Member

@davesmith00000 davesmith00000 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's great! 🔥

I've peppered it with comments that are mostly about bringing it inline with the rest of Indigo's style. Up to you if you feel like doing the work, I can always merge and refine afterwards.

Either way, the effort is much appreciated. 🙏

import scala.annotation.tailrec

trait WaypointPath:
def waypoints: List[Vector2]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I shall forever wonder if this is the right thing to have done, but for consistency, Vector2 should probably be Vertex. They're almost identical, but a vertex is a position on a graph / space where vector2 is an expression of direction. LineSegment uses Vertex, I am open to counter arguments. 😄

final case class LineSegment(start: Vertex, end: Vertex) derives CanEqual:

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additionally, can we replace List with Batch? Batch is much faster and used everywhere.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm glad you mention this because I just realised yesterday that there is both Vertex and Vector2 and I wasn't sure what was the difference 😅 I'll change both.

Comment on lines 91 to 113
private def waypointsWithRadius(
waypoints: List[Vector2],
radius: Double,
loop: Boolean
): List[Vector2] =
val list =
if loop then
waypoints
.appended(waypoints.head)
else waypoints

val distances = list
.foldLeft(List.empty[Vector2]):
case (acc, v2) =>
acc.lastOption match
case Some(v1) =>
val fullDistance = v1.distanceTo(v2)
val cappedDistance = (fullDistance - radius).max(0.0)
val newV2 = lerpVector2(v1, v2, cappedDistance / fullDistance)
acc :+ newV2
case None => List(v2)
if loop then distances.tail.dropRight(1).prepended(distances.last)
else distances
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know how this feels in practice, but generally I think that's a very sensible approach. Waiting for things to land perfectly something is a headache and usually not worth the hassle!

Might be worth making this configurable though? How close is close enough? You have a few 'config' options already, if you add any more, consider creating a config object:

WaypointPathConfig(pathRadius: Radians, loop: Boolean, requiredProximity: Vector2)
object WaypointPathConfig:
  val initial: WaypointPathConfig = WaypointPathConfig(Radians.zero, false, Vector2.one)

requiredProximity is probably a poor name, it could also be a function that lets the user supply a proximity test - but that's proably overkill for the first cut.

@davesmith00000
Copy link
Member

Glorious. 10/10 demo. 👏
image

Copy link
Member

@davesmith00000 davesmith00000 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great. I've reviewed the demo - thanks for doing that. 🙏

@davesmith00000 davesmith00000 merged commit 53cb251 into PurpleKingdomGames:main Apr 9, 2025
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants