Chapter VI in the series of tutorials on how to build a game from scratch with TypeScript and native browser APIs
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.
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
}
}
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.
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!
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 insideUpdate
also meansOnMoveCompleted
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.
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
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.
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: