Building a Game With TypeScript. Pathfinding and Movement 6/7. Instant Locomotion

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

Building a Game With TypeScript. Pathfinding and Movement 6/7. Instant Locomotion

Image by storyset on Freepik

Hello friends! This is the next installment of Building a Game with TypeScript series. Previously we successfully incorporated our Pathfinder for Ship to move around the Grid. This path can lead virtually everywhere but something is still missing. Our Ships... well, they don't move. In this post we are going to finally see some locomotion!


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-5 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. Instant Locomotion
  3. Move that Ship!
  4. Cleanup Previous Position
  5. Re-Highlight Nodes in Range
  6. Freeing Nodes
  7. Prohibit arbitrary moves
  8. Conclusion

Instant Locomotion

We will focus in this episode on simpler version of movement: instant one. That is, as soon as path becomes available, we will see Active Ship instantly traverses through it and stops at the destination. In the next (and final of this Chapter) episode, we will make the movement smooth.


To achieve this, we need to tell Ship to change its position to the next Node in the queue of the calculated path.

This implies that Locomotion has access to Path:

// src/ship/components/locomotion/locomotion.ts
    // ... //
    export class ShipLocomotionComponent implements IComponent {
      // ... //
      private _node: Node
      private _path: Node[] = [] // <--- ADD
      // ... //
      public get Position(): Vector2D {
        return this.Node.Center
      }
      // --- ADD --- //
      public set Path(v: Node[]) {
        this._path = [...v]
      }
      // --- ADD --- //
      // ... //
    }
    
    

Note that I use array destructuring to effectively "copy" an array as a value vs as a reference. ShipLocomotionComponent is going to mutate path. We could run into a number of issues if we were to store the reference to the original Path

Now we can use this _path to read the next Node this Ship needs to "jump" on to:

// src/ship/components/locomotion/locomotion.ts
    // ... //
    export class ShipLocomotionComponent implements IComponent {
      // ... //
      public Update(deltaTime: number): void {
        /* @todo */ // <--- REMOVE
        const next = this._path.shift() // <--- ADD
      }
    }
    

Since _path expected to be a local state of this particular Ship, we are safe to extract the first element of the array. Now, all that is left for us is to assign it a new "position" on the Ship:

// src/ship/components/locomotion/locomotion.ts
    // ... //
    export class ShipLocomotionComponent implements IComponent {
      // ... //
      public Update(deltaTime: number): void {
        const next = this._path.shift() 
        this.Node = next // <--- ADD
      }
    }
    

This will make TypeScript scream, of course. Update runs in the loop, so it's possible, sooner or later, we'll exhaust the array, and next will be undefined. Let's account for that:

// src/ship/components/locomotion/locomotion.ts
    // ... //
    export class ShipLocomotionComponent implements IComponent {
      // ... //
      public Update(deltaTime: number): void {
        const next = this._path.shift() 
        // --- ADD --- //
        if(!next){
          return
        }
        // --- ADD --- //
         
        this.Node = next
      }
    }
    

This makes TS a happy camper again but before we continue, let's also account for another scenario. We only want to move an "active" Ship. Even if path set for the inactive one, it should stay still. Adding this condition is extremely easy:

// src/ship/components/locomotion/locomotion.ts
    // ... //
    export class ShipLocomotionComponent implements IComponent {
      // ... //
      public Update(deltaTime: number): void {
        // --- ADD --- //
        if(!this.Entity.IsActive){
          return
        }
        // --- ADD --- //
        const next = this._path.shift() 
        if(!next){
          return
        }
         
        this.Node = next
      }
    }
    

Move that Ship!

Perfect, now we need to assign a path somehow. Let's make it as obvious as possible by creating a dedicated Move method within the Ship entity:

// src/ship/ship.ts
    // ... //
    export class Ship extends Entity {
      // ... //
      // --- ADD --- //
      public Move(byPath: Node[]): void {
        this._locomotionComponent.Path = byPath
      }
      // --- ADD --- //
    }
    

We can invoke this method at any time after path gets calculated. For example, when Player clicks the second time on the destination Node, confirming their choice (see gif at the begging of this post).

To achieve that, let's preserve the last clicked Node and compare it with "newly" clicked one. If they are the same, thats our cue to send Active Ship into sail:

// src/grid/grid.ts
    export class Grid extends Entity implements IGraph {
      // ... //
      private _currentPath: Node[] = []
      private _targetNode: Node | null = null // <--- ADD
    
      // ... //
      public DeterminePathTo(node: Node): void {
        // ... //
        if(!this.ActiveShip){
          return
        }
    
        // --- ADD --- //
        if(node === this._targetNode){
          this._targetNode = null
          this.ActiveShip.Move(this._currentPath)
          return
        }
    
        this._targetNode = node
        // --- ADD --- //
        
        // ... //
      }
      // ... //
    }
    

Alright, we've been coding blindly for a while. Let's open the browser and see if anything works. And it does! Sorta...

It appears that Ship does move along the path, that's good news. But there is a myriad of weird things happening as well. The previously highlighted nodes are not cleaned up for example. And most importantly, we managed somehow to multiply Ships!

Not to worry though, we still have only one Ship. The duplicates we observe are simply due to the fact that our draw system does not expect Ships to move. If you take a look at ShipDrawComponent and check the Clear method you may notice that we do clean up the position of the Ship. But we never clean up the place it used to be. When we call Clear, a Ship can already change its position and, as a result, the cleaner can miss the previous spot.

Cleanup Previous Position

To cleanup previous position of the Ship we have to track that position first:

// src/ship/components/locomotion/locomotion.ts
    // ... //
    export class ShipLocomotionComponent implements IComponent {
      // ... //
      private _path: Node[] = []
      private _previousPosition: Vector2D | null = null // <--- ADD
    
      // ... //
      // --- ADD --- //
      public get PreviousPosition(): Vector2D | null {
        return this._previousPosition
      }
      // --- ADD --- //
    
      public set Path(v: Node[]) {
        this._path = [...v]
      }
    }
    

We also have to expose it via Ship:

// src/ship/ship.ts
    // ... //
    export class Ship extends Entity {
      // ... //
      public get Position(): Vector2D | null {
        return this._locomotionComponent.Position
      }
    
      // --- ADD --- //
      public get PreviousPosition(): Vector2D | null {
        return this._locomotionComponent.PreviousPosition
      }
      // --- ADD --- //
      
      // ... //
    }
    

And now we can set it properly. When Ship moves to another Node, that is, when its Node is set to something, we update its _previousPosition to reference a former Position:

// src/ship/components/locomotion/locomotion.ts
    // ... //
    export class ShipLocomotionComponent implements IComponent {
      // ... //
      public set Node(v: Node) {
        this._previousPosition = this.Position // <--- ADD
    
        this._node = v
        this._node.Ship = this.Entity
      }
      // ... //
    }
    

We can now use this previous position to clean up after the node:

// src/ship/components/draw/draw.ts
    export class ShipDrawComponent implements IComponent {
      // ... //
      private Clear(): void {
        // --- ADD --- //
        if (this.Entity.PreviousPosition) {
          CanvasLayer.Foreground.ClearRect(
            new Vector2D(
              this.Entity.PreviousPosition.x - Settings.grid.nodeSize / 2,
              this.Entity.PreviousPosition.y - Settings.grid.nodeSize / 2
            ),
            new Vector2D(Settings.grid.nodeSize, Settings.grid.nodeSize)
          )
        }
        // --- ADD --- //
    
        CanvasLayer.Foreground.ClearRect(
          new Vector2D(
            this.Position.x - Settings.grid.nodeSize / 2,
            this.Position.y - Settings.grid.nodeSize / 2
          ),
          new Vector2D(Settings.grid.nodeSize, Settings.grid.nodeSize)
        )
      }
    }
    

And our labor paid off! We can see Ship moves as we want it to:

Something is still amiss, however. We don't erase highlighted Nodes. We also should determine and highlight a new set of nodes in the locomotion range when we done moving. Let's address these!

Re-Highlight Nodes in Range

First, we should de-highlight all Nodes when the movement starts, there is no need for them anymore:

// src/grid/grid.ts
    // ... //
    export class Grid extends Entity implements IGraph {
      // ... //
      public DeterminePathTo(node: Node): void {
        // ... //
        if(node === this._targetNode){
          this.UnHighlightAll() // <--- ADD
          this._targetNode = null
          this.ActiveShip.Move(this._currentPath)
          return
        }
        // ... //
      }
      
      // ... //
      // --- ADD --- //
      private UnHighlightAll(): void {
        this._nodes.forEach(node => {
          node.IsInLocomotionRange = false
          node.IsOnPath = false
        })
      }
      // --- ADD --- //
    }
    

Which works like a charm:

Next piece is a little bit trickier. We need to recalculate Nodes in locomotion range based on the new position of the Ship AND it has to happen after movement is complete. It requires for ShipLocomotionComponent to communicate when Ships done moving.

We can start by introducing a dedicated callback method under Ship. This method expected to be invoked when Ship reaches its destination. From there, we can call good ol' FindAndSetInLocomotionRange to calculate and highlight nodes for us:

// src/ship/ship.ts
    // ... //
    export class Ship extends Entity {
      // ... //
      // --- ADD --- //
      public OnMoveCompleted(node: Node): void {
    this.Node.FindAndSetInLocomotionRange(Settings.ships.locomotion.range)
      }
      // --- ADD --- //
    }
    

Where should we call it from? The obvious candidate is ShipLocomotionComponent sure, but at what point?

Remember in Update we added that little sanity checks "if next is not empty"? Well, what if it is empty? That means there are no more Nodes left on the _path, which implies Ships has reached its destination:

// src/ship/components/locomotion/locomotion.ts
    // ... //
    export class ShipLocomotionComponent implements IComponent {
      // ... //
      public Update(deltaTime: number): void {
        // ... //
    
        const next = this._path.shift()
        if(!next){
          this.Entity.OnMoveCompleted(this._node) // <--- ADD
          return
        }
    
        // ... //
      }
    }
    
    

And it works!

Our assumption is a bit too bold. There could be another reason why the path is empty: Ship had never moved to begin with. Being invoked inside Update also means OnMoveCompleted gets called constantly if Ship has no path. We will fix these in later installments, following the incremental approach. It is safe to say the current solution is good enough for now

You may notice that, while Nodes in the range are recalculated, every time fewer and fewer Nodes are actually highlight. Nothing wrong with the highlighting algorithm though. Take a closer look at the debug info and you may notice something odd.

Freeing Nodes

Though we moved Ship from one Node to another, the previous Node seems to believe it still contains that Ship! That's the unfortunate consequence of the architectural choice we made a while back: to have dual awareness between Node and Ship. Now we have to make an explicit effort and "clean up" Node after Ships moves:

// src/ship/components/locomotion/locomotion.ts
    // ... //
    export class ShipLocomotionComponent implements IComponent {
      // ... //
      public Update(deltaTime: number): void {
        // ... //
    
        this.Node.Ship = null // <--- ADD 
        this.Node = next
      }
    }
    

It definitely helps!

Nodes now appear to be highlighted correctly but our debug tool keeps rendering "Ship" above nodes, despite our effort!

Well, you probably guessed the issue. We removed Ship from Node but we never updated the debug tool itself to account for that. We have to make it right:

// src/node/components/draw/draw.ts
    // ... //
    export class NodeDrawComponent implements IComponent {
      // ... //
      private DrawDebugInfo(): void {
        // ... //
        if (this.Entity.Ship) {
          CanvasLayer.Background.DrawText(
            'Ship',
            new Vector2D(entity.Start.x + 40, entity.Start.y),
            new Color(255, 0, 0, 1)
          )
        // --- ADD --- //
        } else {
          CanvasLayer.Background.ClearRect(
            new Vector2D(entity.Start.x + 40, entity.Start.y - 10),
            new Vector2D(30, 10)
          )
        // --- ADD --- //
        }
      }
    }
    

Back at that NodeDrawComponent, we have to check for the possibility that Ship is not there anymore. And then erase the place where debug message "Ship" could have been

Prohibit arbitrary moves

One last thing before we can wrap up. You may notice that "highlighted" nodes, we worked so hard to get up and running again, do not actually enforce anything. You can click on any node and move Ship there. Even on top of another Ship!

There are multiple ways how we can make Ship abide by the rules. I'm going to do a quick and cheap way: we'll just ignore clicks on non-highlighted nodes:

// src/grid/components/onclick/onclick.ts
    // ... //
    export class GridOnclickComponent extends OnclickComponent {
      // ... //
      public ClickOn(point: Vector2D): void {
        for (const node of this.Entity.Nodes) {
          if (node.IsInLocomotionRange && node.Occupies(point)) { // <--- CHANGE
            this.Entity.DeterminePathTo(node)
          }
        }
      }
    }
    
    

Much better:

FindAndSetInLocomotionRange covers us in both scenarios: it highlights nodes only close enough AND skips Nodes that are already occupied. Perfect!

And since we're in this neighborhood anyway, let's rename DeterminePathTo to something more descriptive. It doesn't just determine the path anymore but also starts the movement of an active Ship. Let's go with something like "CalcPathAndMoveActive":

// src/grid/grid.ts
    // ... //
    export class Grid extends Entity implements IGraph {
      // ... //
      public DeterminePathTo(node: Node): void { // <--- REMOVE
      public CalcPathAndMoveActive(node: Node): void { // <--- ADD
        // ... //
      }
    }
    

TypeScript righteously demands us to make updates in a couple of places. First, Grid's spec:

// src/grid/grid.spec.ts
    // ... //
    describe('>>> Grid', () => {
      // ... //
      describe('Determine path to', () => { // <--- REMOVE
      describe('Calc Path And Move Active', () => { // <--- ADD
        // ... //
        it('should NOT calculate path if there is no currently active ship', () => {
          grid.ActiveShip = null
    
          grid.CalcPathAndMoveActive(destination) // <--- CHANGE
    
          expect(grid.Nodes.some(node => node.IsOnPath)).toBeFalsy()
        })
    
        it('should calculate path if there is currently active ship', () => {
          grid.ActiveShip = mockShipFactory(mockFleetFactory(), grid.Nodes[0])
    
          grid.CalcPathAndMoveActive(destination) // <--- CHANGE
    
          // ... //
        })
      })
    })
    

Then, Grid Click's spec:

// src/grid/components/onclick/onclick.spec.ts
    // ... //
    describe('>>> Grid Click Component', () => {
      // ... //
      it('should update node if user click within it\'s range', () => {
        const spy = jest.spyOn(comp.Entity, 'CalcPathAndMoveActive') // <--- CHANGE
    
        comp.ClickOn(new Vector2D(100, 100))
    
        expect(spy).toBeCalledWith(comp.Entity.Nodes[0])
      })
    })
    

And finally, GridOnclickComponent:

// src/grid/components/onclick/onclick.ts
    // ... //
    export class GridOnclickComponent extends OnclickComponent {
      // ... //
      public ClickOn(point: Vector2D): void {
        for (const node of this.Entity.Nodes) {
          if (node.IsInLocomotionRange && node.Occupies(point)) {
            this.Entity.CalcPathAndMoveActive(node) // <--- CHANGE
          }
        }
      }
    }
    

Note, webpack may not automatically detect changes in spec files. If that's the case, just make some changes in any production file or simply restart the app

Tip: If you are using VSCode or an actual IDE you could benefit from "Rename" feature.

It won't work however, with the spy's attribute name in Grid Click's spec.

At this point, our game should successfully compile and load again. But the test is failing:
No surprise there, they try to warn us that now node has to be in locomotion rage to be clicked:

// src/grid/components/onclick/onclick.spec.ts
    // ... //
    describe('>>> Grid Click Component', () => {
      // ... //
      it('should update node if user click within it\'s range', () => {
        const spy = jest.spyOn(comp.Entity, 'CalcPathAndMoveActive')
    
        comp.Entity.Nodes[0].IsInLocomotionRange = true // <--- ADD
        
        comp.ClickOn(new Vector2D(100, 100))
    
        // ... //
      })
    
    })
    

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

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

Conclusion

And that's it! In this installment we finally made our Ship moving along the board. It was a long journey with a few setbacks and surprise issues but you made it all the way to the end! Congrats!

In the next post, we are going to add animation to Ship's locomotion and wrap up this Chapter. I'll see you there!

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!