Chapter VI in the series of tutorials on how to build a game from scratch with TypeScript and native browser APIs
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.
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!
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.
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:
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!
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 byduration
. This means thet
value may never achieve the perfect value1
. 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!
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.
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
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.
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: