Pathfinding and Movement 7. Animated Locomotion

Chapter VI in the series of tutorials on how to build a game from scratch with TypeScript and native browser APIs

Pathfinding and Movement 7. Animated Locomotion

Image by catalyststuff on Freepik

Hello friends! Here we are, the final episode of Chapter VI! It was a long trip through the woods of algorithms of pathfinding, graphs, and queues. In this installment, we are to wrap things up by implementing the animated movement. Let's dive in!


In Chapter VI “Pathfinding and Movement ”, we finally make our Ships alive! You can find other Chapters of this series here:

Feel free to switch to the movement-pathfinding-6 branch of the repository. It contains the working result of the previous posts and is a great starting point for this one.


Table of Contents

  1. Introduction
  2. Animated Locomotion
  3. Lerp
  4. Intro Animated Locomotion Component
  5. Intro Current Position
  6. Moving slowly
  7. Setting things in (loco)motion
  8. Calling OnMoveCompleted once
  9. Setting Ship before Animation Completes
  10. Conclusion

Animated Locomotion

At its core, animated locomotion adheres to the same rules as an instant one. It changes Ship's position over time, stepping at every Node on its path. In the end, it has to reach its destination, and that destination has to be the last Node on the _path.

There are differences, of course. While instant locomotion arrives immediately at the center of the "next" Node, animated one smoothes this process. It progressively changes the location of the Ship even between Nodes:

We don't have to "store" this position in between Nodes long term since it doesn't bear any meaning for the game, it's pure aesthetics. But we do have to keep it in mind to make the animation possible.

One way of achieving this is by "lerping" between nodes. Lerp, or linear interpolation, in its simplest form, is a function that, having two data points, returns you a value in between these points based on a given third parameter.

How is that helpful to us? Consider the mentioned data points as Ship's position: "current" Node and "next" Node. At any point of animated movement, we expect Ship to be somewhere in between these two points. Where exactly depends on how much time passed since Ship started its movement and the speed of this movement.

Of course, to make this happen we need someone to calculate linear interpolation for us. Time to write some code!


Lerp

This is a generic functionality, not in any way dependent on our game logic. So, its only natural to put it into the utils folder:

// src/utils/lerp/lerp.ts
    export const Lerp = (start: number, end: number, t: number): number => {}
    

And do not forget to update the barrel files. Here:

// src/utils/lerp/index.ts
    export * from './lerp'
    

And here:

// src/utils/index.ts
    // ... //
    export * from './graph'
    export * from './lerp' // <--- ADD
    export * from './lifecycle'
    // ... //
    

Our Lerp expects 3 numeric values: start, end, and t. The last one determines the value the function will return between start and end.

I am going to take formula from the wikipedia, please refer to that article if you're interested in the math behind it:

// src/utils/lerp/lerp.ts
    export const Lerp = (start: number, end: number, t: number): number => {
      return start * (1 - t) + end * t // <--- ADD
    }
    

And we can easily test it:

// src/utils/lerp/lerp.spec.ts
    import { Lerp } from './lerp'
    
    describe('>>> Lerp', () => {
      it('should linearly interpolate between two values', () => {
    
      })
    })
    
    

For example, imagine start is 0, end is 50. Given t is 0.5 (right in the middle of these two) the result should be 25:

// src/utils/lerp/lerp.spec.ts
    // ... //
    describe('>>> Lerp', () => {
      it('should linearly interpolate between two values', () => {
        expect(Lerp(0, 50, 0.5)).toBe(25) // <--- ADD
      })
    })
    

Or, given start is 20, end is 80 and t is 0 then the output has to be the very "start":

// src/utils/lerp/lerp.spec.ts
    // ... //
    describe('>>> Lerp', () => {
      it('should linearly interpolate between two values', () => {
        expect(Lerp(0, 50, 0.5)).toBe(25)
        expect(Lerp(20, 80, 0)).toBe(20) // <--- ADD
      })
    })
    

And it works:

Good! But since Ship's position defined as Vector2D it will also be helpful to have a vector representation of lerp. That is, given two Vector2D values, find another Vector2D in between them based on some value t.

There is no better place to store this functionality than Vector2D class. The method is static, however, since we supply values directly to the method, and do not depend on any particular instance of Vector2D:

// src/utils/vector2D/vector2D.ts
    export class Vector2D {
      // ... //
      public static Lerp(start: Vector2D, end: Vector2D, t: number): Vector2D {
    
      }
    }
    

Interpolation is quite easy to achieve. All we have to do is to interpolate every element of the vector independently and construct a new one:

// src/utils/vector2D/vector2D.ts
    import { Lerp } from '@/utils' // <--- ADD
    
    export class Vector2D {
      // ... //
      public static Lerp(start: Vector2D, end: Vector2D, t: number): Vector2D {
        return new Vector2D(Lerp(start.x, end.x, t), Lerp(start.y, end.y, t)) // <--- ADD
      }
    }
    

With this out of our way, we can focus on the locomotion itself.


Intro Animated Locomotion Component

As we discussed above, Animated Locomotion is very close at its heart to the Instant locomotion we have already built. Moreover, we can envision "Animated" version as an extended, more specialized case of locomotion. In Object-Oriented programming there are some tools that can help us model this kind of relationship: inheritance and polymorphism.

Indeed, we can reuse structure and some logic from the LocomotionComponent and add new stuff on top of it, by simply adding derived class: ShipLocomotionAnimatedComponent:

// src/ship/components/locomotion/locomotion-animated.ts
    import { ShipLocomotionComponent } from './locomotion'
    
    export class ShipLocomotionAnimatedComponent extends ShipLocomotionComponent {}
    
    

And re-export it:

// src/ship/components/locomotion/index.ts
    export * from './locomotion'
    export * from './locomotion-animated' // <--- ADD
    

And then use it in place of a parent class:

// src/ship/ship.ts
    // ... //
    import { Fleet } from '@/fleet'
    import {
      ShipDrawComponent,
      ShipLocomotionComponent,
      ShipLocomotionAnimatedComponent // <--- ADD
    } from './components'
    import { Node } from '@/node'
    // ... //
    export class Ship extends Entity {
      // ... //
      constructor(public readonly Factory: Fleet, node: Node) {
        super()
    
        this._locomotionComponent = new ShipLocomotionAnimatedComponent(node) // <--- CHANGE
      }
      }
      // ... //
    }
    

TypeScript peacefully accepted us instantiating ShipLocomotionAnimatedComponent instead of ShipLocomotionComponent even though _locomotionComponent is of a latter type. No surprise here, the animation component derives from the regular locomotion component and hence is compatible with it. Moreover, Ship doesn't really care which version we're using so we could potentially swap between these two implementations. Nice!

If you open the browser, you notice nothing has changed. The game behaves exactly as it used to. Since ShipLocomotionAnimatedComponent has no custom implementation, it's effectively a shallow proxy to the parent class. Even tests are passing but just to be precise, let's groom them a bit:

// src/ship/ship.spec.ts
    import {
      Ship,
      mockShipFactory,
      ShipDrawComponent,
      ShipLocomotionAnimatedComponent // <--- CHANGE
    } from '@/ship'
    
    describe('>>> Ship', () => {
      it('should awake and update all Components', () => {
        // ... //
        // --- CHANGE --- //
        const spyLocomotionCompAwake = jest.spyOn(ShipLocomotionAnimatedComponent.prototype, 'Awake')
        const spyLocomotionCompUpdate = jest.spyOn(ShipLocomotionAnimatedComponent.prototype, 'Update')
        // --- CHANGE --- //
        // ... //
      })
    })
    

Simply swap the component we're spying on. Code should still compile, all tests should still pass:


Intro Current Position

Let's dissect the task at hand. Animated movement means we need to find a way to slowly change the position of the Ship on the canvas. Until now, we treated position and Node Ship stands on, pretty much interchangeably. Indeed, look at our Position getter, it virtually returns the center of the current Node.

Now, with animated movement that's no longer true. The actual, physical position of the Ship can be anywhere between Nodes even though Ship still "belongs" to one Node or another.

This means we need to separate these two concepts. And we can do that by introducing a dedicated field:

// src/ship/components/locomotion/locomotion-animated.ts
    import { Vector2D } from '@/utils' // <--- ADD
    import { ShipLocomotionComponent } from './locomotion'
    
    export class ShipLocomotionAnimatedComponent extends ShipLocomotionComponent {
      private _currentPosition: Vector2D // <--- ADD
    }
    

Moreover, we have to use this field when we want to draw or clean up Ship. ShipDrawComponent references Ship's Position property for that, so we have no choice but to override it:

// src/ship/components/locomotion/locomotion-animated.ts
    // ... //
    export class ShipLocomotionAnimatedComponent extends ShipLocomotionComponent {
      private _currentPosition: Vector2D
    
      // --- ADD --- //
      public get Position(): Vector2D {
        return this._currentPosition
      }
      // --- ADD --- //
    }
    

This seems to have broken our game. ShipDrawComponent now screams that there is no position at all. It is right to complain, we never set _currentPosition which in the JavaScript world means its value is undefined.

We must apply some value from the very start. What the value should be? The current node's center, of course! There was never any movement happened yet but Ship always starts its existence on some Node:

// src/ship/components/locomotion/locomotion-animated.ts
    import { Vector2D } from '@/utils'
    import { Node } from '@/node' // <--- ADD
    // ... //
    export class ShipLocomotionAnimatedComponent extends ShipLocomotionComponent {
      // ... //
      // --- ADD --- //
      constructor(node: Node) {
        super(node)
        this._currentPosition = node.Center
      }
      // --- ADD --- //
    }
    

That seems to have fixed the issue. However, you may notice that Ship now behaves oddly:

This is exactly what we were looking to achieve, actually! Ship keeps occupying and moving along the Nodes, which we can verify by looking at the "Ship" debug message. But the "visual" location is now detached from that. It never changes because, well... we never updated _currentPosition. Let's do that!


Moving slowly

The meat and bone of the locomotion happen in Update, where we change the position of the Ship over time when a new iteration of the game loop happens. Naturally, this is the place we need to override! We can start by mimicking a behavior we already got accustomed to: the instant locomotion.

First, we have to determine the next Node this Ship should move to. Just like the last time, we can do that by extracting the first item from the _path:

// src/ship/components/locomotion/locomotion-animated.ts
    // ... //
    export class ShipLocomotionAnimatedComponent extends ShipLocomotionComponent {
      private _currentPosition: Vector2D
      private _next: Node // <--- ADD
      
      // ... //
      // --- ADD --- //
      public Update(deltaTime: number): void {
        if(!this.Entity.IsActive){
          return
        }
    
        this._next = this._path[0]
        if(!this._next){
          this.Entity.OnMoveCompleted(this._node)
          return
        }
        
        this.Node.Ship = null
        this.Node = this._next
        this._path.shift()
    
      }
      // --- ADD --- //
    }
    

The first thing you may notice is that I added a guard clause at the very beginning of Update to make sure we're dealing only with an active Ship. I also set up _next as a private field vs a local variable. We will need it across many methods soon enough so it should save us some time. Finally, I separated accessing the first element of the _path from extracting it from an array. This is also something that will soon come in handy.

But TS is not happy with our decision. It complains that _path and _node, being private fields, are not accessible in child classes. No problem at all, we can fix that easily:

// src/ship/components/locomotion/locomotion.ts
    // ... //
    export class ShipLocomotionComponent implements IComponent {
      // ... //
      protected _node: Node // <--- CHANGE
      protected _path: Node[] = [] // <--- CHANGE
      // ... //
    
    }
    

Much better now! Our game behaves the same way again: Ship moves but we don't see it. Well, why not fix that?

To actually see the change in the position of Ship we have to update the _currentPosition:

// src/ship/components/locomotion/locomotion-animated.ts
    // ... //
    export class ShipLocomotionAnimatedComponent extends ShipLocomotionComponent {
      // ... //
      private Update(deltaTime: number): void {
        // ... //
        this.Node.Ship = null
        this.Node = this._next
        
        this._currentPosition = this._next.Center // <--- ADD
        
        this._path.shift()
      }
    }
    

And we got our "instant" locomotion working again:

Good, now we'll focus on the animation. Take a look again at what we are trying to achieve:

We want to keep moving between Nodes but this movement should be smooth, or in other words interpolated.

Indeed, to achieve animation pretty much all we have to do is to lerp the current position of the Ship between the previous Node's center and next's. But what should be the t parameter?

Well, it has to be the "progress", how much a Ship has already progressed on its way between nodes. We can track that if we know how much time elapsed since we started the movement and how long we expect the entire animation to last. Let's document all this:

// src/ship/components/locomotion/locomotion-animated.ts
    // ... //
    import { Settings } from '@/settings' // <--- ADD
    import { ShipLocomotionComponent } from './locomotion'
    
    export class ShipLocomotionAnimatedComponent extends ShipLocomotionComponent {
      // ... //
      private _next: Node
      // --- ADD --- //
      private _timeElapsed: number
      private _startPosition: Vector2D
      private _endPosition: Vector2D
      // --- ADD --- //
    
    
      // ... //
      // --- ADD --- //
      private Animate(deltaTime: number): void {
        const duration = Settings.ships.locomotion.duration / 1000
    
        this._currentPosition = Vector2D.Lerp(this._startPosition, this._endPosition, this._timeElapsed / duration)
        this._timeElapsed += deltaTime
      }
      // --- ADD --- //
    }
    

I added a new private Animate method that expects deltaTime as a parameter. deltaTime, if you remember, is passed to Update and indicates how much time elapsed since the last frame. We use it to increment _timeElapsed to make sure it stays up to date.

We then use long-ago defined "duration" of locomotion. Divided by 1000, it gives us how many seconds we expect the animation to last. Formula this._timeElapsed / duration gives us the t value for lerp: how much did the Ship already progress. Or, in other words, how much time relative to the entire duration of the animation has already passed.

I also defined but have not yet assigned _startPosition and _endPosition fields. Let's assign them now:

// src/ship/components/locomotion/locomotion-animated.ts
    // ... //
    export class ShipLocomotionAnimatedComponent extends ShipLocomotionComponent {
      // ... //
      public Update(deltaTime: number): void {
        // ... //
      }
      // --- ADD --- //
      private StartAnim(): void {
        this._startPosition = this._node.Center
        this._endPosition = this._next.Center
    
        this._timeElapsed = 0
      }
      // --- ADD --- //
    
      private Animate(deltaTime: number): void {
        // ... //
      }
    }
    

When we start the animation, we prepare all crucial parameters: start and end position, and restart the clock.

Good, but we miss something here: when do we consider the animation completed? Indeed, if we were to call these methods, our game wouldn't know when to stop and would endlessly try to move the poor ship!

We have to fix that. One way to do so is by checking the _timeElapsed. If its value is more than the duration of the animation, that means we exceeded the lifetime of the animation and have to stop:

// src/ship/components/locomotion/locomotion-animated.ts
    // ... //
    export class ShipLocomotionAnimatedComponent extends ShipLocomotionComponent {
      // ... //
      private Animate(deltaTime: number): void {
        const duration = Settings.ships.locomotion.duration / 1000
    
        if(this._timeElapsed < duration){ // <--- ADD
          this._currentPosition = Vector2D.Lerp(this._startPosition, this._endPosition, this._timeElapsed / duration)
          this._timeElapsed += deltaTime
          return // <--- ADD
        } // <--- ADD
    
        this.CompleteAnim() // <--- ADD
      }
    
      // --- ADD --- //
      private CompleteAnim(): void {
        this._currentPosition = this._endPosition
      }
      // --- ADD --- //
    }
    

I added yet another private method: CompleteAnim and I call it when we know that _timeElapsed is either greater or equal to duration. When the animation is complete I reset the current position to the _endPosition just to make sure there are no little miscalculations.

What miscalculations are these? Well, _timeElapsed is not necessarily can be divided perfectly by duration. This means the t value may never achieve the perfect value 1. As a result, the lerp could get to the 99% of value when time has passed.

One more important thing should happen when animation is complete. This is the moment when we can remove the first element from the _path. This way when a new frame starts and Update is called, it can pick up and set _next :

// src/ship/components/locomotion/locomotion-animated.ts
    // ... //
    export class ShipLocomotionAnimatedComponent extends ShipLocomotionComponent {
      // ... //
      private CompleteAnim(): void {
        this._currentPosition = this._endPosition
    
        this._path.shift() // <--- ADD
      }
    }
    

Good! Now, as we established above, the animation happens between two nodes. But it doesn't necessarily cover the entire locomotion process. You see, the movement in our little game is rather a set of repeated sequential animations. Take a look at the reference gif again:

In the very first example, we want Ship to move from (0, 0) to (0, 2). To do that, Ship travels to (0, 1) in an animated way and THEN travels from (0, 1) to (0, 2) in a similar animated way. So, there are actually 2 animations happening here, one after another.

That means we require more "steps" in our code. We already have "start animation" -> "animate" -> "complete animation". But we also need a code that starts and ends the entire process of locomotion.

We could do that directly in Update but to make it more readable, let's add distinguished methods:

// src/ship/components/locomotion/locomotion-animated.ts
    // ... //
    export class ShipLocomotionAnimatedComponent extends ShipLocomotionComponent {
      // ... //
      private StartLocomotion(): void {} // <--- ADD
    
      private StartAnim(): void {
         // ... //
      }
    
      private Animate(deltaTime: number): void {
         // ... //
      }
    
      private CompleteAnim(): void {
         // ... //
      }
    
      private CompleteLocomotion() : void {} // <--- ADD
    }
    

Note that I deliberately placed StartLocomotion before all animation methods and CompleteLocomotion after all of them. There is no technical reason for that of course, but hopefully, it helps communicate the order of operations:

"Start Locomotion" -> "Start Anim" -> "Animate" -> "Complete Anim" -> "Complete Locomotion". This is the lifecycle of our animated locomotion!

What should we do to start the locomotion? Nothing incredibly complicated here, just set an indicator that locomotion is in progress from now on. When the locomotion completes, we should stop the indicator. And for all that to happen, we actually need a notion of a "progress":

// src/ship/components/locomotion/locomotion-animated.ts
    // ... //
    export class ShipLocomotionAnimatedComponent extends ShipLocomotionComponent {
      // ... //
      private _endPosition: Vector2D
      private _isInProgress = false // <--- ADD
    
      // ... //
      private StartLocomotion(): void {
        this._isInProgress = true // <--- ADD
      }
    
      // ... //
      private CompleteLocomotion() : void {
        this._isInProgress = false // <--- ADD
      }
    }
    

A simple flag _isInProgress should do the trick. Also, CompleteLocomotion is a perfect time to tell the outside world that locomotion is done:

// src/ship/components/locomotion/locomotion-animated.ts
    // ... //
    export class ShipLocomotionAnimatedComponent extends ShipLocomotionComponent {
      // ... //
      private CompleteLocomotion() : void {
        this._isInProgress = false 
        this.Entity.OnMoveCompleted(this._node) // <--- ADD
      }
    
    }
    

Similarly, we will benefit from a flag that indicates that animation between nodes is in progress. This will help us from accidentally cutting the animation short:

// src/ship/components/locomotion/locomotion-animated.ts
    // ... //
    export class ShipLocomotionAnimatedComponent extends ShipLocomotionComponent {
      // ... //
      private _isInProgress = false
      private _isAnimInProgress = false // <--- ADD
    
      // ... //
      private StartAnim(): void {
        this._isAnimInProgress = true // <--- ADD
        // ... //
      }
      // ... //
    
      private CompleteAnim(): void {
        this._currentPosition = this._endPosition
        this._isAnimInProgress = false // <--- ADD
    
        this._path.shift()
      }
    }
    

And finally, CompleteAnim is a good place to change the occupancy of the node:

// src/ship/components/locomotion/locomotion-animated.ts
    // ... //
    export class ShipLocomotionAnimatedComponent extends ShipLocomotionComponent {
      // ... //
      private CompleteAnim(): void {
        this._currentPosition = this._endPosition
        this._isAnimInProgress = false
    
        // --- ADD --- //
        this.Node.Ship = null
        this.Node = this._next
        // --- ADD --- //
        
        this._path.shift()
      }
    
    }
    

Now we are ready to tie everything together!


Setting things in (loco)motion

First, we need almost nothing from the Update any longer. It worked well for instant locomotion, but we have another goal.

// src/ship/components/locomotion/locomotion-animated.ts
    // ... //
    export class ShipLocomotionAnimatedComponent extends ShipLocomotionComponent {
      // ... //
      public Update(deltaTime: number): void {
        if(!this.Entity.IsActive){
          return
        }
    
        this._next = this._path[0]
        // --- REMOVE --- //
        if(!this._next){
          this.Entity.OnMoveCompleted(this._node)
          return
        }
    
        this.Node.Ship = null
        this.Node = this._next
    
        this._path.shift()
    
        this._currentPosition = this._next.Center
        // --- REMOVE --- //
      }
    }
    

And the order of operations is quite simple: assuming _path is not empty we can check if locomotion already takes place. If not... what are we waiting for? Let's start it!

// src/ship/components/locomotion/locomotion-animated.ts
    // ... //
    export class ShipLocomotionAnimatedComponent extends ShipLocomotionComponent {
      // ... //
      public Update(deltaTime: number): void {
        // ... //
        this._next = this._path[0]
    
        // --- ADD --- //
        if(this._next){
          if(!this._isInProgress){
            this.StartLocomotion()
            return
          }
        }
        // --- ADD --- //
      }
    }
    

If locomotion has started BUT no animation in progress, its time to start one:

// src/ship/components/locomotion/locomotion-animated.ts
    // ... //
    export class ShipLocomotionAnimatedComponent extends ShipLocomotionComponent {
      // ... //
      public Update(deltaTime: number): void {
        // ... //
        this._next = this._path[0]
    
        
        if(this._next){
          if(!this._isInProgress){
            this.StartLocomotion()
            return
          }
    
          // --- ADD --- //
          if(!this._isAnimInProgress){
            this.StartAnim()
            return
          }
          // --- ADD --- //
        }
        
      }
    }
    

Well, and you guessed it: if animation is on the way, all we have to do is to keep animating!

// src/ship/components/locomotion/locomotion-animated.ts
    // ... //
    export class ShipLocomotionAnimatedComponent extends ShipLocomotionComponent {
      // ... //
      public Update(deltaTime: number): void {
        // ... //
        this._next = this._path[0]
    
        if(this._next){
          if(!this._isInProgress){
            this.StartLocomotion()
            return
          }
    
          
          if(!this._isAnimInProgress){
            this.StartAnim()
            return
          }
    
          return this.Animate(deltaTime) // <--- ADD
        }
        
      }
    }
    

But what if there is no _next? Actually, the only case we care about is if at the time locomotion is still processing. That's the moment we know we have reached the last node and its the time to stop locomotion:

// src/ship/components/locomotion/locomotion-animated.ts
    // ... //
    export class ShipLocomotionAnimatedComponent extends ShipLocomotionComponent {
      // ... //
      public Update(deltaTime: number): void {
        // ... //
        this._next = this._path[0]
    
        if(this._next){
          if(!this._isInProgress){
            this.StartLocomotion()
            return
          }
    
          
          if(!this._isAnimInProgress){
            this.StartAnim()
            return
          }
    
          return this.Animate(deltaTime)
        }
    
        // --- ADD --- //
        if(this._isInProgress){
          this.CompleteLocomotion()
        }
        // --- ADD --- //
      }
    }
    

And finally, we are ready to check out how it works. If you open the browser you should see the beautiful animated Ships flying around. Amazing job!

This is fantastic but before we wrap up, I want to address a couple of nuances.


Calling OnMoveCompleted once

The first one is OnMoveCompleted. You may remember that last time we chatted about how wrong it is for OnMoveCompleted to be constantly called from ShipLocomotionComponent. The reason was that we didn't have enough checks to make sure OnMoveCompleted is called exactly once when locomotion is complete.

But in ShipLocomotionAnimatedComponent we finally fixed this issue! Indeed, CompleteLocomotion, which invokes OnMoveCompleted is called only once when a) there is no _next left AND b) locomotion is in progress. Since we immediately set _isInProgress to false when this happens, we have a reasonable guarantee CompleteLocomotion won't be invoked until the next locomotion starts.

Here's a gif to demonstrate that it works as expected:

Im using VSCode debugger here to illustrate that breakpoint hits only once vs every frame. You can use console.log instead if you prefer


Setting Ship before Animation Completes

Another minor issue has to do with the question: "when should we set data vs animation?"

What I mean by that is, ideally, we don't want the logic of the game to be a hostage of visual representation. It's not always possible to avoid because games are so rich visual mediums but if we can, we should.

Take a look at our game again, pay attention this time to the "Ship" debug message:

It's probably hard to notice so let's ease our debugging a bit by slowing down the Ship:

// src/settings/settings.ts
    // ... // 
    export const Settings = Object.freeze({
      // ... //
      ships: {
        // ... //
        locomotion: {
          range: 3,
          duration: 1000 // <--- CHANGE
        },
      }
      // ... //
    })
    

Now it takes Ship the whole second to complete the path from one node to another:

We don't want Ship to be such a slowpoke but it's the perfect temporary solution to debug something. Now we should clearly see that Ship gets assigned to the Node after animation is complete.

It's not a big deal in our case but generally speaking, we don't probably want to depend on animation that much. What if there is a hiccup and the animation never gets to the perfect ending? Let's play it safe and assign Ship before the animation:

// src/ship/components/locomotion/locomotion-animated.ts
    // ... //
    export class ShipLocomotionAnimatedComponent extends ShipLocomotionComponent {
      // ... //
      private StartAnim(): void {
        // ... //
        this._endPosition = this._next.Center
    
        // --- ADD --- //
        this.Node.Ship = null
        this.Node = this._next
        // --- ADD --- //
        
        this._timeElapsed = 0
      }
    
      // ... //
      private CompleteAnim(): void {
        // ... //
        this._isAnimInProgress = false
    
        // --- REMOVE --- //
        this.Node.Ship = null
        this.Node = this._next
        // --- REMOVE --- //
        
        this._path.shift()
      }
    }
    

And now Ship starts occupying the new Node and frees up the old one immediately:

Exactly what we wanted! Now, don't forget to revert the duration of Ship's locomotion back to 300!

At this point, our code should successfully compile with npm start and all test should pass with npm t:

You can find the complete source code of this post in the movement-pathfinding-7 branch of the repository.


Conclusion

On this note, it's time to conclude this installment and this chapter! What a journey it was: we started on a solid foundation with a Node set up on a Grid and Ships being ready to take their place. We got inputs ready as well but with all that Ships were completely immobile!

In this Chapter, we first spent a bit of time defining our goals: how we want our Ships to move around in this world, and what locomotion even means for us. We then set up Pathfinder and Graph, and brushed up a bit on different algorithms. Finally, we got to prepare Locomotion components: starting with a simple, instant one and wrapping up with nice smooth animation.

I cannot thank you enough for joining me on this path! I hope you enjoyed it, learned something new, and had fun along the way!

The future looks even more exciting: our next stop is State Machina, one of the most common patterns you can find in gamedev. It will help us make Ships a bit smarter by laying a foundation for numerous behaviors Ship may have: being "active", chase, attack, flee, defending itself, etc.

I cannot wait to see you in this new Chapter!


I would love to hear your thoughts! If you have any comments, suggestions, questions, or any other feedback, don’t hesitate to send me a message on Twitter or email! If you enjoy this series, please share it with others. It really helps me keep working on it. Thank you for reading, and I’ll see you next time!

This is Chapter N in the series of tutorials "Building a game with TypeScript". Other Chapters are available here:

Like this piece? Share it!