Path Animation (Multi-Step Movement)

Animate an entity along a path with chained animations, useful for smooth multi-tile movement, patrol routes, or cinematic sequences.

Every example animates the entity’s draw_pos: its drawn position in fractional tile coordinates, a Vector. (An entity’s x, y and pos are pixels. Before 0.3.0 the tile properties were draw_x/draw_y; those names now raise ValueError.)

Quick Example

import mcrfpy

class PathAnimator:
    """Animate an entity along a series of waypoints."""

    def __init__(self, entity, path, step_duration=0.2, easing=None):
        """
        Args:
            entity: The Entity to animate
            path: List of (x, y) waypoints in grid coordinates
            step_duration: Seconds per step
            easing: mcrfpy.Easing value (default: EASE_IN_OUT)
        """
        self.entity = entity
        self.path = path
        self.step_duration = step_duration
        self.easing = easing if easing is not None else mcrfpy.Easing.EASE_IN_OUT
        self.index = 0
        self.on_complete = None
        self.on_step = None

    def start(self):
        """Begin path animation from current position."""
        self.index = 0
        self._animate_next()

    def _animate_next(self):
        """Animate to the next waypoint."""
        if self.index >= len(self.path):
            # Path complete
            if self.on_complete:
                self.on_complete(self)
            return

        target_x, target_y = self.path[self.index]

        # Called after each leg; a diagonal waypoint takes two legs (X, then Y)
        def on_step_complete(target, prop, value):
            pos = self.entity.draw_pos
            if (pos.x, pos.y) == (target_x, target_y):
                # Arrived at this waypoint
                if self.on_step:
                    self.on_step(self, self.index)
                self.index += 1
            self._animate_next()

        # Move along X, then along Y: one leg per animation. draw_pos is in
        # tiles (entity.x/entity.y are pixel coordinates).
        pos = self.entity.draw_pos
        if pos.x != target_x:
            self.entity.animate("draw_pos", (target_x, pos.y), self.step_duration,
                                 self.easing, callback=on_step_complete)
        elif pos.y != target_y:
            self.entity.animate("draw_pos", (pos.x, target_y), self.step_duration,
                                 self.easing, callback=on_step_complete)
        else:
            # Already at position, skip to next
            self.index += 1
            self._animate_next()


# Usage
path = [(5, 5), (5, 8), (8, 8), (8, 5)]  # Square patrol route
animator = PathAnimator(enemy, path, step_duration=0.3)
animator.on_complete = lambda a: print("Patrol complete!")
animator.start()

Diagonal Movement Support

Animate draw_pos straight to each waypoint, and both axes move together:

import mcrfpy

class DiagonalPathAnimator:
    """Path animator with diagonal movement support."""

    def __init__(self, entity, path, step_duration=0.2, easing=None):
        self.entity = entity
        self.path = path
        self.step_duration = step_duration
        self.easing = easing if easing is not None else mcrfpy.Easing.EASE_IN_OUT
        self.index = 0
        self.on_complete = None
        self.on_step = None

    def start(self):
        """Begin path animation."""
        self.index = 0
        self._animate_next()

    def _on_anim_done(self, target, prop, value):
        """One animation per waypoint, so one callback per step."""
        if self.on_step:
            self.on_step(self, self.index)
        self.index += 1
        self._animate_next()

    def _animate_next(self):
        """Animate to next waypoint (diagonal if needed)."""
        if self.index >= len(self.path):
            if self.on_complete:
                self.on_complete(self)
            return

        target_x, target_y = self.path[self.index]
        pos = self.entity.draw_pos

        if (pos.x, pos.y) == (target_x, target_y):
            # Already at target, move to next
            self.index += 1
            self._animate_next()
            return

        # One animation moves both axes (draw_pos is in tiles)
        self.entity.animate("draw_pos", (target_x, target_y), self.step_duration,
                             self.easing, callback=self._on_anim_done)


# Usage - diagonal path
path = [(2, 2), (5, 5), (8, 2), (5, 5)]  # Diamond pattern
animator = DiagonalPathAnimator(entity, path, step_duration=0.4)
animator.start()

Looping Patrol Route

Create an enemy that continuously patrols a route:

import mcrfpy

class PatrolAnimator:
    """Endlessly loop through a patrol path."""

    def __init__(self, entity, path, step_duration=0.3, pause_duration=0.5):
        """
        Args:
            entity: Entity to animate
            path: List of (x, y) waypoints
            step_duration: Time per movement step
            pause_duration: Pause at each waypoint
        """
        self.entity = entity
        self.path = path
        self.step_duration = step_duration
        self.pause_duration = pause_duration
        self.index = 0
        self.is_paused = False
        self.is_running = False

    def start(self):
        """Start the patrol loop."""
        self.is_running = True
        self._move_to_next()

    def stop(self):
        """Stop the patrol."""
        self.is_running = False

    def _move_to_next(self):
        """Move to the next waypoint."""
        if not self.is_running:
            return

        target_x, target_y = self.path[self.index]

        def on_arrive(target, prop, value):
            if not self.is_running:
                return
            # Pause at waypoint
            def resume(timer, runtime):
                if self.is_running:
                    self.index = (self.index + 1) % len(self.path)
                    self._move_to_next()

            mcrfpy.Timer(f"patrol_pause_{id(self)}", resume,
                        int(self.pause_duration * 1000), once=True)

        # Animate movement (draw_pos is in tiles)
        pos = self.entity.draw_pos
        if (pos.x, pos.y) != (target_x, target_y):
            self.entity.animate("draw_pos", (target_x, target_y), self.step_duration,
                                 mcrfpy.Easing.EASE_IN_OUT, callback=on_arrive)
        else:
            # Already there, move to next after pause
            on_arrive(None, None, None)


# Usage
patrol_route = [(3, 3), (3, 7), (7, 7), (7, 3)]
guard = PatrolAnimator(guard_entity, patrol_route, pause_duration=1.0)
guard.start()

# Later, to stop:
# guard.stop()

Path with Variable Speeds

Different speeds for different path segments:

import mcrfpy

class VariableSpeedPath:
    """Path animator with per-segment speed control."""

    def __init__(self, entity, waypoints):
        """
        Args:
            entity: Entity to animate
            waypoints: List of (x, y, duration, easing) tuples
                       duration and easing are optional
        """
        self.entity = entity
        self.waypoints = []
        for wp in waypoints:
            if len(wp) == 2:
                self.waypoints.append((wp[0], wp[1], 0.3, mcrfpy.Easing.EASE_IN_OUT))
            elif len(wp) == 3:
                self.waypoints.append((wp[0], wp[1], wp[2], mcrfpy.Easing.EASE_IN_OUT))
            else:
                self.waypoints.append(wp)

        self.index = 0
        self.on_complete = None

    def start(self):
        self.index = 0
        self._next()

    def _next(self):
        if self.index >= len(self.waypoints):
            if self.on_complete:
                self.on_complete(self)
            return

        x, y, duration, easing = self.waypoints[self.index]

        def done(target, prop, value):
            self.index += 1
            self._next()

        # Animate draw_pos (tiles); one animation covers both axes
        pos = self.entity.draw_pos
        if (pos.x, pos.y) != (x, y):
            self.entity.animate("draw_pos", (x, y), duration, easing, callback=done)
        else:
            done(None, None, None)


# Usage - different speeds for different segments
waypoints = [
    (5, 5),                                        # Default speed
    (5, 10, 0.5),                                   # Slower
    (10, 10, 0.2, mcrfpy.Easing.LINEAR),             # Fast, linear
    (10, 5, 1.0, mcrfpy.Easing.EASE_OUT),            # Slow arrival
]
path = VariableSpeedPath(entity, waypoints)
path.start()

Pathfinding Integration

Use with McRogueFace’s built-in pathfinding:

import mcrfpy

def animate_to_target(entity, grid, target_x, target_y, step_duration=0.15):
    """
    Find path and animate entity to target.

    Args:
        entity: Entity to move
        grid: Grid with walkable tiles (unused directly - entity.find_path
               consults the grid the entity is attached to)
        target_x, target_y: Destination in grid coordinates
        step_duration: Animation time per tile
    """
    # Use the entity's own pathfinding (A* algorithm)
    path = entity.find_path((target_x, target_y))

    if not path:
        print("No path found!")
        return None

    # Consume the path into a waypoint list
    waypoints = []
    while path:
        step = path.walk()
        waypoints.append((step.x, step.y))

    # Create and start animator
    animator = PathAnimator(entity, waypoints, step_duration)
    animator.start()
    return animator


# Usage
animator = animate_to_target(player, grid, 15, 10)
if animator:
    animator.on_complete = lambda a: print("Arrived at destination!")

Camera Following Path

Animate camera to follow the entity along the path:

import mcrfpy

class CameraFollowingPath:
    """Path animator that also moves the camera."""

    def __init__(self, entity, grid, path, step_duration=0.2):
        self.entity = entity
        self.grid = grid
        self.path = path
        self.step_duration = step_duration
        self.index = 0
        self.on_complete = None

    def start(self):
        self.index = 0
        self._next()

    def _next(self):
        if self.index >= len(self.path):
            if self.on_complete:
                self.on_complete(self)
            return

        x, y = self.path[self.index]

        def done(target, prop, value):
            self.index += 1
            self._next()

        # Animate entity (draw_pos is in tiles)
        pos = self.entity.draw_pos
        if (pos.x, pos.y) != (x, y):
            self.entity.animate("draw_pos", (x, y), self.step_duration,
                                 mcrfpy.Easing.EASE_IN_OUT, callback=done)
        else:
            done(None, None, None)
            return

        # Animate camera to follow (center is in pixel space; tile size here is 16px)
        self.grid.animate("center", ((x + 0.5) * 16, (y + 0.5) * 16),
                           self.step_duration, mcrfpy.Easing.EASE_IN_OUT)


# Usage
path = [(5, 5), (5, 10), (10, 10)]
mover = CameraFollowingPath(player, grid, path)
mover.on_complete = lambda m: print("Journey complete!")
mover.start()

Key Concepts

  1. Animation callbacks: Chain animations using the callback parameter
  2. Index tracking: Keep track of current position in path
  3. Completion handling: Fire callbacks when path is complete
  4. Diagonal support: draw_pos is a Vector, so one animation moves both axes
  5. Pathfinding integration: Use entity.find_path(target) (or grid.find_path(start, end)) for dynamic routes

Tips

  • Store animator references to prevent garbage collection during animation
  • Use shorter step durations for more responsive movement
  • Combine with camera following for smooth cinematic sequences
  • For NPCs, add pause_duration at waypoints to feel more natural
  • Check for obstacles before starting path animation
  • Cancel paths gracefully when interrupted by player action