Introduction
"When you wish to instruct, be brief" - Cicero
This is a summary of the knowledge I have gathered along my career and I think is essential to get started making games and content with PixiJS.
PixiJS is an HTML5 Creation Engine and can be used to create beautiful digital content with the fastest, most flexible 2D WebGL renderer.
We could spend pages and pages discussing what PixiJS is and isn't, or if you should or shouldn't use it over other engines, frameworks, or libraries but I refuse to do so. I assume that if you are here is because you already made your mind so I won't try to convince you.
Let's get straight to teaching!
Getting Started
Before we even start...
This tutorial won't teach you to code from scratch. I will assume you already know your way around code and object-oriented programming.
Git and some shell (console) experience will be good but not mandatory. If you know any object-oriented programming you will be just fine.
This ain't your grandma's Javascript...
If you came here expecting to write javascript between two <script>
tags in your .html file, oh boy I have bad news for you.
Web development might have started with only a text editor and dumping code into a single .js file but in today's spooky fast world of javascript libraries, going commando ain't gonna cut it.
This... guide? course? tutorial? will use Typescript and it will be designed to work with pixi-hotwire, a boilerplate to get you started fast without the hassle of how to create and maintain a standard web project with a bundler. (If you have a bit more experience in web development, feel free to tinker and try any bundler of your liking.)
pixi-hotwire?
In case you realize you have no idea what a bundler is or why you need one, worry not! I've created a base template project called pixi-hotwire. It is the simplest boilerplate I could build. It goes from nothing to watching your code running with the least amount of configurations.
It is a just-add-npm solution that sets you up with typescript, pixi.js and webpack. It also will make a final build folder for you instead of you having to pluck manually files out of your project folder to get the uploadeable result of your hard work.
Typescript?
I'll be honest with you: I hate Javascript. I hate the meme of truthy and falsy values. I hate the implicit type coercion. I hate the dynamic context binding. I hate a lot of javascript... But I must endure, adapt, overcome.
Enter Typescript: Typescript will force your hand to keep your code as strictly typed as possible, and if you need to use type coercion and really javascripty code, you must consciously choose to make the data type as any
.
As a nice bonus, you get intellisense on Visual Studio Code (which is Microsoft's fancy name for code auto-complete).
I will never scream (PIXI.
)
I feel that doing import * as PIXI from "pixi.js"
so that I can go PIXI.Sprite
, PIXI.Container
, PIXI.Assets
, etc, is silly and makes me feel clumsy. I will use named imports: import { Sprite, Container, Assets } from "pixi.js"
and that allows me to just use the class name without adding PIXI
everywhere.
I could say it is better for three-shaking (the step where the bundler doesn't add to your code stuff that you never use) or that I fight the smurf naming convention... but at the end of the day I just like named imports better. Sorry, not sorry.
Enough talk, have at you!
Be patient. When the time is right, the code will be shown in this column.
Let's start by making sure you have all the tools and materials.
You will need:
- NodeJS (it comes with
npm
). - pixi-hotwire boilerplate.
- A text editor (consider Visual Studio Code but any would do)
- A web browser from this era. (Sorry internet explorer, you are out!)
A quick overview of what you will find inside pixi-hotwire:
src
folder: Contains the typescript code for your project.static
folder: Contains all the assets (non-code) for your project.package.json
file: Contains a list of all the libraries and tools you need along with some script code to run and build your project. The heart and soul of anynpm
project.dist
folder: Won't be there yet. Here you will find the ready-to-upload build when you want to share your game.
Once you have cloned or downloaded pixi-hotwire you will need to grab all the dependencies (stored inside the package.json
file), to do so you will need to use a shell (console), navigate to the project folder and use the command npm install
to read all the dependencies and download them into the node_modules
folder.
When the progress finishes you now have access to new stuff that begins with npm
!
npm run start
will convert your typescript into javascript as you write it and let you test the game!- "as you write" means you just need to save the file you are working in order to see the changes, no need to run it again!
npm run build
will create a package for you to upload to your webserver. Find it inside the folderdist
(short for distributable)- Don't try to "just double click" the index.html file there. It may work, it may not. This is meant to upload into a webserver or service. When your game is ready you can try uploading to itch.io.
Run npm run start
and open your web browser on the website http://localhost:1234
.
localhost
means "your own computer" and 1234
is the port where this web server is running. Nobody else will see your game running on their localhost
. You will need to export it and upload it somewhere.
Try hitting F12 and finding the Javascript Console. That will be your best friend when needing to know why something is or isn't working. That is where console.log()
will write and where you will see any errors that you might have made.
Finally some code!
Now, for the actual code inside the
index.ts
file
import { Application, Sprite } from 'pixi.js'
const app = new Application<HTMLCanvasElement>({
view: document.getElementById("pixi-canvas") as HTMLCanvasElement,
resolution: window.devicePixelRatio || 1,
autoDensity: true,
backgroundColor: 0x6495ed,
width: 640,
height: 480
});
const clampy: Sprite = Sprite.from("clampy.png");
clampy.anchor.set(0.5);
clampy.x = app.screen.width / 2;
clampy.y = app.screen.height / 2;
app.stage.addChild(clampy);
First, we see the import
statement. As previously stated, I prefer named imports to just importing everything under a big all-caps PIXI
object.
After that, we create an app
instance of PixiJS Application
. This object is a quick way to set up a renderer and a stage to drop stuff on screen and be ready to render. You can see that I call for an HTML element by id pixi-canvas
, this element can be found on the index.ejs
file. (The ejs file is template that webpack will use to create your final index.html
with your code bundle referenced inside)
As a parameter for the Application
object, we give it some options. You can see and explore the full list in PixiJS official docs.
Then, I create a Sprite
with the Sprite.from(...)
method, this is a super powerful shortcut that can take as a parameter one of many things, among which we can find:
- A
Texture
object. - The name of a
Texture
object you loaded previously. (usingAssets
, we will see this when we get to loading assets) - The URL of an image file
Can you guess what we used here?
Well, I hope you guessed option three, "the URL of an image file" because that is the correct option.
By saying "clampy.png"
it means "find a file called clampy.png in the root of my asset folder". You will see that our asset folder is called static
and if you check in there, indeed there it is, clampy.png
!
Finally, I set the position of Clampy and add it to the screen by doing an addChild()
to the app.stage
.
You will learn soon enough what the stage and why is it called addChild()
but for now it should suffice to know that app.stage
is the name for "the entire screen" and everything that you want to show needs to be appended by using addChild()
.
Homework!
Oh boy, you thought you could get away?
Try these on for size!
- Try moving the
clampy.png
into a folder (keeping it insidestatic
!)- Now, try referencing the new location to make Clampy appear again
- Try using an image from the internet, say from imgur
- Make sure your file ends with
.png
or.jpg
, which means it is the actual image file.
- Make sure your file ends with
- Try an image from a random website. (it may work, it may not, don't panic)
- Did you get a
blocked by CORS
error on the javascript console? CORS is a safety measure to prevent a webpage from calling other webpages without your permission, this is why we have our own images on thestatic
folder!.
- Did you get a
Putting stuff on screen
(and being in control of said stuff)
The DisplayList
In my not-so-humble opinion, the best way to handle 2d graphics.
It all starts with an abstract class: The DisplayObject
. Anything that can be shown on the screen must inherit from this abstract class at some point in his genealogy. When I want to refer to "anything that can be placed on the screen" I will use the generic term DisplayObject
.
In PixiJS your bread and butter are going to be Container
and Sprite
. Sprites can show graphics (if you come from the getting started you've already seen it in action) and Containers are used to group sprites to move, rotate and scale them as a whole. You can add a Container to another Container and keep going as deep as you need your rabbit hole to go.
Imagine a cork bulletin board, some photos, and a box of thumbtacks. You could pin every photo directly to the board... or you could pin some photos to another and then move them all together by moving the photo you pinned every other photo to.
In this relationship, the Container is called the parent and the DisplayObjects that are attached to this parent are called children.
Things are rendered back-to-front, which means that children will always be covering their parents. Between siblings (children with the same parent), the render order will start from the first added child and move to the last one, making the last child added show on top of all his siblings (unless you use the zOrder
property of any display object. However, this property only works between siblings. A child will never be able to be behind his parent).
I know that it has list in the name, but it actually looks more like a tree. If you are a nerd like me and understand data structures, imagine a tree structure where each node can have any number of nodes attached that can have more nodes attached...
Finally, we have the root of everything, the granddaddy of them all, the greatest of grandfathers, and we shall call it the Stage
. The stage is just a regular container that the Application
class creates for us and feeds it to the Renderer
to... well render it.
Have some code
import { Application, Sprite, Container } from 'pixi.js'
const app = new Application<HTMLCanvasElement>({
view: document.getElementById("pixi-canvas") as HTMLCanvasElement,
resolution: window.devicePixelRatio || 1,
autoDensity: true,
backgroundColor: 0x6495ed,
width: 640,
height: 480
});
const conty: Container = new Container();
conty.x = 200;
conty.y = 0;
app.stage.addChild(conty);
const clampy: Sprite = Sprite.from("clampy.png");
clampy.x = 100;
clampy.y = 100;
conty.addChild(clampy);
Ok, let's make a simple example to test Container
and Sprite
.
It should look fairly familiar, but let's go real quick over it...
We create conty the Container
and add it to the app.stage
. That means that it is added to the screen and should get rendered but it is empty...
So we create clampy the clamp Sprite
like the last time but instead of adding it directly to the screen, we add it to conty the Container
.
Now, see how we set Clampy's position to 100, 100 but if you run it, you will see that it is not showing up there but way more to the side... why?
Well, we added Clampy to conty and he has an x
value of 200. That means that Clampy now has a global x
value of 300!
Homework!
Ok, let's try some things and see what happens!
- Try making more Containers and setting different positions.
- Try to be able to predict where the sprite will end up by keeping a mental map of all the parents and grandparents it has.
- Try adding the same sprite twice to two different containers... Did it work?
- Spoiler alert, it won't work. A DisplayObject can't have two parents, it will become unattached from his current parent to go to the new one.
- Make more sprites with
Sprite.from()
to test multiple sprites on screen.
- Try using a
Sprite
as aContainer
.- This works because
Sprite
actually inherits fromContainer
!
- This works because
- Try rotations and scales on parents. See how the origin of the parent becomes relevant to the position of the children?
- Try to make a sprite rotate around his center without using
origin
oranchor
but instead centering it on the origin of his parent and rotating his parent. - Fun fact: in ye' old times this was the only way to have a sprite rotate around his center!
- Try to make a sprite rotate around his center without using
A quick overview of the Display Objects you can use out of the box.
Note: I will just give you a quick overview and a link to the official PixiJS API, if you want to know everything about an object you should got here.
Container
an example where
bigConty
is the papa oflittleConty
.
const bigConty: Container = new Container();
bigConty.scale.set(2); // You can use set and only one value to set x and y
bigConty.position.x = 100;
bigConty.y = 200; // this is a shortcut for .position.y and it also exists one for .position.x
app.stage.addChild(bigConty);
const littleConty: Container = new Container();
// position has a copy setter. It won't use your reference but copy the values from it!
littleConty.position = new Point(300,200);
bigConty.addChild(littleConty);
A bit of a silly example, it won't show anything on screen since containers don't have content by themselves.
The most basic class you will have. While it doesn't have any graphical representation on its own, you can use it to group other objects and affect them as a unit.
PixiJS Container API
Methods and properties that you will use most frequently:
addChild(...)
: You use this to add something to the container.removeChild(...)
: You use this to remove said something to the container.children
: An array of the objects you added to the container.position
,scale
andskew
: If you care to know, those are two PixiJS Pointposition.x
andposition.y
have shortcuts on.x
and.y
- Even if
position
is an object, setting it to a new position or to another object's position won't break it since it has a setter that copies the value! This makesone.position = another.position
totally safe!
rotation
andangle
: Rotation is in radians while angle is in degrees. Changing one updates the other.width
andheight
: The size of a container it's defined by the size of a box that contains all his children. This means it changes if the children move.- Changing these values modifies the scale of the object.
interactive
,on(...)
andoff(...)
: With this, you can manage your event listeners. It will be really useful when we get to see interacting with your display objectsgetBounds(...)
: Will give you a rectangle of this object. It will be very useful for detecting basic collisions.destroy()
This will remove the object from his parent and render it unusable forever. After that, get rid of any reference and the garbage collector should eat it.
Particle Container
There isn't much to them, it's like a regular container
const particleConty: ParticleContainer = new ParticleContainer();
// Pretty much everything that worked on a Container will work with a ParticleContainer.
A Particle Container is a special kind of container designed to go fast. To achieve this extra speed you sacrifice some functionality.
The rules for Particle Containers are:
- No grandchildren: Children of a ParticleContainer can't have children.
- No fancy stuff: Filters and Masks won't work here.
- Single texture: All Sprites inside a ParticleContainer must come from the same texture. If you are using texture atlases this is easier to achieve. If you ever see one of your sprites inside a ParticleContainer looking like a garbled mess of another sprite it is probably because they were not sharing a texture.
While the name seems to indicate that Particle Containers should be used for Particles you can use them as super fast containers when you need to render a lot of objects.
Sprite
The simple example from the getting started
const clampy: Sprite = Sprite.from("clampy.png");
clampy.anchor.set(0.5);
// setting it to "the middle of the screen
clampy.x = app.screen.width / 2;
clampy.y = app.screen.height / 2;
app.stage.addChild(clampy);
Here we use again the shortcuts for
position
.
The simplest way to show a bitmap on your screen. It inherits from Container
so all the properties from above apply here too!
PixiJS Sprite API
Methods and properties that you will use most frequently:
Sprite.from(...)
: This is a static method to create new sprites. It does some black magic inside it so that it can take a lot of different kinds of parameters. (You technically can use thenew Sprite(...)
way of creating sprites but this is way easier).
This method can take any of the following parameters:- Name of a texture you loaded before (we will see how to load assets in another chapter).
- URL of an image or video. This can be an absolute one (
http://somedomain.com/image.png
) or relative (assets/image.png
). - A PixiJS Base texture
- A canvas or video HTML element
anchor
: This allows you to set where you want the center of this sprite to be.0, 0
sets the origin to the left and top, and1, 1
means the right and bottom.tint
: Fast way to color a sprite. The default value is white0xFFFFFF
and this means no color change. This is done inside the shader and is F R E E. No performance hit whatsoever!
Graphics
There is SO much stuff you can do with graphics... Let's just make a circle at 100,100
const graphy: Graphics = new Graphics();
// we give instructions in order. begin fill, line style, draw circle, end filling
graphy.beginFill(0xFF00FF);
graphy.lineStyle(10, 0x00FF00);
graphy.drawCircle(0, 0, 25); // See how I set the drawing at 0,0? NOT AT 100, 100!
graphy.endFill();
app.stage.addChild(graphy); //I can add it before setting position, nothing bad will happen.
// Here we set it at 100,100
graphy.x = 100;
graphy.y = 100;
I can't stress this enough: Do draw your graphics relative to their own origin and then move the object. Don't try to draw it directly on the screen position you want
This class allows you to make primitive drawings like rectangles, circles, and lines. It is really useful when you need masks, hitboxes, or want a simple graphic without needing a bitmap file. It also inherits from Container
.
PixiJS Graphics API
Methods and properties that you will use most frequently:
beginFill(...)
andendFill()
: You mark the beginning and end of a fill. Every shape you draw between the begin and end calls will be filled by the color you specify.lineStyle(...)
: Defines color, thickness, and other properties of the line of your drawings.moveTo(...)
,lineTo(...)
,drawRect(...)
anddrawCircle(...)
: The most basics tools for drawing. There are more complex ones like polygons and beziers! Check them out in the API.clear()
: for when you need to erase everything and start over.
Text
Check PixiJS Textstyle Editor to make the textstyle easily.
const styly: TextStyle = new TextStyle({
align: "center",
fill: "#754c24",
fontSize: 42
});
const texty: Text = new Text('私に気づいて先輩!', styly); // Text supports unicode!
texty.text = "This is expensive to change, please do not abuse";
app.stage.addChild(texty);
(Japanese text is optional, I used it just to show Unicode support)
Oh boy, we could have an entire chapter dedicated to text but for now, just the basics.
Text has AMAZING support for Unicode characters (as long as your chosen font supports it) and it is pretty consistent on how it looks across browsers.
PixiJS Text API
Tips:
- Go to PixiJS Textstyle Editor to make your text look exactly like you want it to.
text
: Contains the text to show. Changing this is expensive. If you need your text to change every frame (for example, a score) consider usingBitmapText
- To use custom fonts you need to add them as webfonts to your webpage. If you know your html-fu you can do this or you can check the fonts section on how to load assets
BitmapText
PixiJS Textstyle Editor can be used to make the object for the
BitmapFont.from(...)
thingy.
// If you need to know, this is the expensive part. This creates the font atlas
BitmapFont.from("comic 32", {
fill: "#ffffff", // White, will be colored later
fontFamily: "Comic Sans MS",
fontSize: 32
})
// Remember, this font only has letters and numbers. No commas or any other symbol.
const bitmapTexty: BitmapText = new BitmapText("I love baking, my family, and my friends",
{
fontName: "comic 32",
fontSize: 32, // Making it too big or too small will look bad
tint: 0xFF0000 // Here we make it red.
});
bitmapTexty.text = "This is cheap";
bitmapTexty.text = "Change it as much as you want";
app.stage.addChild(bitmapTexty);
Remember, symbols won't show by default. Your sentence might not mean the same without commas.
The faster but more limited brother to Text
, BitmapText uses a BitmapFont to draw your text. That means that changing your text is just changing what sprites are shown on screen, which is really fast. Its downside is that if you need full Unicode support (Chinese, Japanese, Arabic, Russian, etc) you will end up with a font atlas so big that performance will start to go down again.
PixiJS BitmapText API
Tips:
- PixiJS can create a BitmapFont on the fly from a regular font.
BitmapFont.from(...)
will take an object similar to a TextStyle and make a font for you to use!- You will give this generated font a
name
and that will be the unique identifier. Creating another font with the same name will overwrite that style! - I advise making the BitmapFont in white as you can tint it later with the BitmapText object.
- When creating a font this way, you can pass a custom set of characters to include in the BitmapFont atlas.
BitmapFont.ALPHA
,BitmapFont.NUMERIC
, andBitmapFont.ALPHANUMERIC
are constants you can use or you can create your own string.
- You will give this generated font a
- The screen size of the BitmapText is achieved by scaling the BitmapFont. This can lead to ugly-looking text if you are pushing your sizes too far apart. Try creating fonts that are closer in size to your needs.
Filters
Stunning effects with no effort!
PixiJS has a stunning collection of filters and effects you can apply either to only one DisplayObject or to any Container and it will apply to all its children!
I won't cover all the filters (at the time of writing there are 37 of them!!) instead, I will show you how to use one of the pre-packaged filters and you will have to extrapolate the knowledge from there.
You can see a demo of the filters or go directly to Github to see what package to install.
Creating and using filters is so easy that I wasn't sure if I needed to make this part or not
// import the filters
// If you are using pixijs < 6 you might need to import `filters`
import { BlurFilter } from "pixi.js";
// Make your filter
const myBlurFilter = new BlurFilter();
// Add it to the `.filters` array of any DisplayObject
clampy.filters = [myBlurFilter];
This is a section that I almost didn't make because using filters is super simple but I made it anyway so you guys know there is a huge list of filters ready to use.
In essence, create a filter, add it to the filters array of your display object, and you are done!
Particles
Make it rain
Make sure you dropped your emitter.json somewhere inside your src folder
import * as particleSettings from "../emitter.json";
const particleContainer = new ParticleContainer();
app.stage.addChild(particleContainer);
const emitter = new particles.Emitter(particleContainer, Texture.from("particleTexture.png"), particleSettings);
emitter.autoUpdate = true; // If you keep it false, you have to update your particles yourself.
emitter.updateSpawnPos(200, 100);
emitter.emit = true;
For handling particles, PixiJS uses their own format and you need to install the library: npm install pixi-particles
and you can create them using the Particle Designer
Once we have our particles looking good on the editor we download a .json
file and we will feed the parsed object into the Emitter
constructor along with a container for our particles (it can be any Container
but if you have a lot of particles you might want a ParticleContainer
), and the Texture
(or an array of textures) you want your particles to use.
To make that .json
into something usable we need to either load it with Assets
or just add it as part of our source code and import
it.
Methods and properties that you will use most frequently:
playOnce(...)
: Starts emitting particles and optionally calls a callback when particle emission is complete.playOnceAndDestroy(...)
: Starts emitting particles, sets autoUpdate to true, and sets up the Emitter to destroy itself when particle emission is complete.autoUpdate
: Enable or disable the automatic update.update(seconds)
: If autoUpdate is false, you need to manually move the time forward. This method wants the time in seconds.updateSpawnPos(...)
: Move your emitter aroundresetPositionTracking()
: When you move your emitter, it will leave a trail behind itself. If you want to teleport it without a trail, call this method after setting the spawn position.
Context
whose grandma is it anyway?
Hopefully an example will make it easier to see...
class A {
private myName: string = "I am A";
public method: Function;
}
class B {
private myName: string = "I am B";
public printName() {
// Here... what does "this" means?!
console.log(this.myName);
}
}
const a = new A();
const b = new B();
// I assign a.method
a.method = b.printName;
a.method(); // This will print "I am A"
b.printName(); // This will print "I am B"
// This will create a new function where `b` is the `this` for that function
a.method = b.printName.bind(b);
a.method(); // This now prints "I am B"
They are calling the same method... and getting different results!?
Imagine you have a phone number that calls your grandma Esther. Let's say that this number is 555-1234
. When you dial 555-1234
you call your grandma.
Now you dictate me the number, 555-1234
and I dial, it calls my grandma Rachel... Wait what?!
We saw a moment ago that to reference a method or member of a class we need to prepend the this.
keyword. In javascript, the this
object, will become "whoever owns the function" (the context of who and where called the function).
So when you need to pass a method as a parameter (let's call it b.printName
), when that function parameter gets called, inside the code of that function, this
is not b
!
To fix this, you need to tell the function to bind
(attach) the this
(context) object. This is done by adding .bind(b)
to the function like so: b.printName.bind(b)
Back to the telephone example, for me to call your grandma Esther you give me 555-1234.bind(estherGrandson)
and that makes it so that when I use the number, it understands that the original owner it's you and thus calls your grandma Esther.
This might make no sense now but you will start seeing a lot of .bind()
in future chapters and this is the primer.
If you want a more formal explanation with fewer grandmas, you can try MDN webdocs
Splitting code
it's not me, it's you
We have been dumping all our code directly into index.ts
and this is all fine and dandy for quick tests and learning stuff but if we are going to move forward we need to learn how to split up our codebase into different files.
A quick preview of Scenes
This is my
Scene.ts
file
import { Container, Sprite } from "pixi.js";
export class Scene extends Container {
private readonly screenWidth: number;
private readonly screenHeight: number;
// We promoted clampy to a member of the class
private clampy: Sprite;
constructor(screenWidth: number, screenHeight: number) {
super(); // Mandatory! This calls the superclass constructor.
// see how members of the class need `this.`?
this.screenWidth = screenWidth;
this.screenHeight = screenHeight;
// Now clampy is a class member, we will be able to use it in another methods!
this.clampy = Sprite.from("clampy.png");
this.clampy.anchor.set(0.5);
this.clampy.x = this.screenWidth / 2;
this.clampy.y = this.screenHeight / 2;
this.addChild(this.clampy);
}
}
In a future chapter I will provide an example of my scene management system but for now, let's start by making a class that inherits from Container and that will be the lifecycle of our examples and demos. This will allow us to have methods that come from an object instead of being global functions.
Start by creating a new .ts
file and create a class that extends Container
. In my case, I created Scene.ts
and created the Scene
class. (I like to name my files with the same name as my class and to keep my classes starting with an uppercase).
See the export
keyword? In this wacky modular world, other files can only see what you export.
And in the constructor of this new class, let's dump the code of our getting started.
And this is how my
index.ts
looks now
import { Application } from 'pixi.js'
import { Scene } from './scenes/Scene'; // This is the import statement
const app = new Application<HTMLCanvasElement>({
view: document.getElementById("pixi-canvas") as HTMLCanvasElement,
resolution: window.devicePixelRatio || 1,
autoDensity: true,
backgroundColor: 0x6495ed,
width: 640,
height: 480
});
// pass in the screen size to avoid "asking up"
const sceny: Scene = new Scene(app.screen.width, app.screen.height);
app.stage.addChild(sceny)
Finally, let's go back to the index.ts
file and create and add to the screen a new Scene
instance!
First, we will need to import it, Visual Studio Code usually can suggest you the import path, otherwise you just import
it with curly braces and the relative or absolute path of the file.
From now on, the examples will show you "scenes" instead of raw code to plaster in your index.ts
. You have been warned.
Animating stuff
shake it baby!
Let's sum it up quickly, we have 3 main ways of animating stuff:
- Animated Sprites: Frame-by-frame animations made out of multiple textures.
- Ticker and some Kinematic equations: During each frame, we check the speed of something and move it accordingly.
- Tweens: Short for "in-betweens", it's a way of telling an object "go to that place in this many seconds" and have it go without having to think the physics.
There is another kind of animations called bone animation and while there are PixiJS plugins for loading two of the biggest formats out there, Spine and DragonBones, we won't be seeing them here.
AnimatedSprite
Don't freak out, we will use some javascripty stuff.
import { AnimatedSprite, Container, Texture } from "pixi.js";
export class Scene extends Container {
constructor() {
super();
// This is an array of strings, we need an array of Texture
const clampyFrames: Array<String> = [
"clampy_sequence_01.png",
"clampy_sequence_02.png",
"clampy_sequence_03.png",
"clampy_sequence_04.png"
];
// `array.map()` creates an array from another array by doing something to each element.
// `(stringy) => Texture.from(stringy)` means
// "A function that takes a string and returns a Texture.from(that String)"
const animatedClampy: AnimatedSprite = new AnimatedSprite(clampyFrames.map((stringy) => Texture.from(stringy)));
// (if this javascript is too much, you can do a simple for loop and create a new array with Texture.from())
this.addChild(animatedClampy); // we just add it to the scene
// Now... what did we learn about assigning functions...
animatedClampy.onFrameChange = this.onClampyFrameChange.bind(this);
}
private onClampyFrameChange(currentFrame): void {
console.log("Clampy's current frame is", currentFrame);
}
}
Clampy sequence assets don't exist. I lied to you. You will need your own assets.
Frame-by-frame animations go with 2d games like toe and dirt. Animated sprite has got your back. (This inherits from Sprite
)
PixiJS AnimatedSprite API
Tips and stuff:
- You make this by passing an array of
Texture
objects.- A good way to do this is by loading a spritesheet directly. We will touch that on the loader recipe.
- The constructor has a parameter
autoUpdate
that comes by default on true.- If you set it to false, you need to use
.update(...)
manually.
- If you set it to false, you need to use
- A bunch of things that do exactly what they say...
.loop
.currentFrame
.playing
.play()
,.stop()
,.gotoAndPlay()
and.gotoAndStop()
.onFrameChange
: Assign a function to this and it will get called every time the frame changes.- It receives the
currentFrame
as a parameter, use it to call your functions at the exact frame!
- It receives the
Ticker
Trigger warning: some math ahead
Try to read a bit into the math before jumping into this one
import { Container, Sprite, Ticker } from "pixi.js";
export class Scene extends Container {
private readonly screenWidth: number;
private readonly screenHeight: number;
private clampy: Sprite;
private clampyVelocity: number = 5;
constructor(screenWidth: number, screenHeight: number) {
super();
this.screenWidth = screenWidth;
this.screenHeight = screenHeight;
this.clampy = Sprite.from("clampy.png");
this.clampy.anchor.set(0.5);
this.clampy.x = 0; // we start it at 0
this.clampy.y = this.screenHeight / 2;
this.addChild(this.clampy);
// See the `, this` thingy there? That is another way of binding the context!
Ticker.shared.add(this.update, this);
// If you want, you can do it the bind way
// Ticker.shared.add(this.update.bind(this));
}
private update(deltaTime: number): void {
this.clampy.x = this.clampy.x + this.clampyVelocity * deltaTime;
if (this.clampy.x > this.screenWidth) {
// Woah there clampy, come back inside the screen!
this.clampy.x = 0;
}
}
}
Ok, PixiJS Ticker
is an object that will call a function every frame before rendering and tell you how much time has passed since the last frame.
But why is that useful? Well, when we know the velocity of something and we know for how long it has been moving we can predict where the object would be!
In a silly example: If I know you can walk one block in 5 minutes, I know that if 10 minutes have passed you should be two blocks away.
Now, the actual spooky math is:
New Position = Old Position + Velocity * Time Passed
We can use this math every single frame to move an object after we give it a velocity! (Sorry if this was very obvious for you.)
Now that we are on board on what we need to do, let's see how to use the PixiJS Ticker.
You can create one for yourself or just use the Ticker.shared
instance that is already created for quick use. You just attach a function you want to be called every frame with the .add()
and that's it!
PixiJS Ticker API
Tips and Tricks:
- If you make your own
Ticker
, remember tostart()
it. - The function you give in
.add(...)
can have a parameter time.- That parameter is a number in "how many frames passed at 60fps". So 1 means you are running at 60 fps. 2 means you are running at 30fps.
- I personally hate it.
.deltaMS
and.elapsedMS
will give you the amounts of milliseconds that passed between each call.elapsedMS
is raw milliseconds whiledeltaMS
can get affected by timescales or max framerates..FPS
good place to ask how many fps are you getting right now.
Tweens
We will use my own creation, Tweedle.js you can get it by
npm install tweedle.js
import { Tween, Group } from "tweedle.js";
import { Container, Sprite, Ticker } from "pixi.js";
export class Scene extends Container {
private clampy: Sprite;
constructor(screenWidth: number, screenHeight: number) {
super();
this.clampy = Sprite.from("clampy.png");
this.clampy.anchor.set(0.5);
this.clampy.x = screenWidth / 2;
this.clampy.y = screenHeight / 2;
this.addChild(this.clampy);
Ticker.shared.add(this.update, this);
// See how these chains all together
new Tween(this.clampy.scale).to({ x: 0.5, y: 0.5 }, 1000).repeat(Infinity).yoyo(true).start();
// This is the same code, but unchained
// const tweeny = new Tween(this.clampy.scale);
// tweeny.to({ x: 0.5, y: 0.5 }, 1000);
// tweeny.repeat(Infinity);
// tweeny.yoyo(true);
// tweeny.start();
}
private update(): void {
//You need to update a group for the tweens to do something!
Group.shared.update()
}
}
Note that you will still some ticker or loop to update the tweens
Ok, doing the math to move something with a given speed is fun and all, but what if I just want my element to do something in a certain amount of time and not bother to check if it already arrived, that it doesn't overshoot? To make it worse, what if I want some elastic moving movement?
Tweens to the rescue! For tweens, I'll be using Tweedle.js which is what I use every day in my job and it's my own fork of tween.js.
Just like with PixiJS API, I won't copypaste the full API, feel free to check the full Tweedle.js API Docs
Before we start, you need to remember You need to update tweens too!. This is usually achieved by updating the tween group they belong to. If you don't want to handle groups you can just use the shared static one that lives in Group.shared
, just remember to update it.
Let's move through some of the basics:
- Create a tween with
new Tween(objectToChange);
- A second parameter can be passed if you want to use your custom groups, otherwise a shared static group that lives in
Group.shared
will be used.
- A second parameter can be passed if you want to use your custom groups, otherwise a shared static group that lives in
- Tell your tween the target and duration with
.to( { property : targetValue }, duration)
- All durations of Tweedle are in Milliseconds
- Once you are ready to let it rip, just call
.start()
Ok, but what other cool things can Tweedle do?
- Repeat your movement with
.repeat(amountOfTimesToRepeat)
- When repeating, use
.yoyo(true)
to have the tween reverse on each loop so it goes back and forth
- When repeating, use
- Spice up your movement with
.easing(Easing.Elastic.Out)
- There are a lot of easings to pick!
- You can use bezier curves or tween between colors, it is some advanced stuff but it can be done. It's all in the Interpolation
Getting interactive
In web HTML5 games we usually rely on 2 kinds of inputs: Some sort of pointing device (be it a mouse or a touchscreen) and the keyboard.
We will explore how to use both of them and then we will do a quick overview on how you can trigger some of your own custom events.
--
Pointer Events
mouse... touch.... both?
import { Container, FederatedPointerEvent, Sprite } from "pixi.js";
export class Scene extends Container {
private clampy: Sprite;
constructor(screenWidth: number, screenHeight: number) {
super();
this.clampy = Sprite.from("clampy.png");
this.clampy.anchor.set(0.5);
this.clampy.x = screenWidth / 2;
this.clampy.y = screenHeight / 2;
this.addChild(this.clampy);
// events that begin with "pointer" are touch + mouse
this.clampy.on("pointertap", this.onClicky, this);
// This only works with a mouse
// this.clampy.on("click", this.onClicky, this);
// This only work with touch
// this.clampy.on("tap", this.onClicky, this);
// Super important or the object will never receive mouse events!
this.clampy.eventMode = 'dynamic';
}
private onClicky(e: FederatedPointerEvent): void {
console.log("You interacted with Clampy!")
console.log("The data of your interaction is super interesting", e)
// Global position of the interaction
// e.global
// Local (inside clampy) position of the interaction
// clampy.toLocal(e.global)
// or the "unsafe" way:
// (e.target as DisplayObject).toLocal(e.global)
// Remember Clampy has the 0,0 in its center because we set the anchor to 0.5!
}
}
PixiJS has a thing that whenever you click or move your mouse on the screen it checks what object were you on, no matter how deep in the display list that object is, and lets it know that a mouse happened.
(If curious, that thing is a plugin called Federated Events System)
The basic anatomy of adding an event listener to an imput is:
yourObject.on("stringOfWhatYouWantToKnow", functionToBeCalled, contextForTheFunction)
and the second super important thing is:
yourObject.eventMode = 'dynamic'
About Event Modes
eventMode
is a new addition in v7.2.0 that replaces the old interactive
property. These are the valid values:
none
: Ignores all interaction events, even on its children.passive
: Does not emit events and ignores all hit testing on itself and non-interactive children. Interactive children will still emit events.auto
: Does not emit events and but is hit tested if parent is interactive. Same asinteractive = false
in v7static
: Emit events and is hit tested. Same asinteractive = true
in v7dynamic
: Emits events and is hit tested but will also receive mock interaction events fired from a ticker to allow for interaction when the mouse isn't moving
Touch? Mouse? I want it all!
The web has moved forward since its first inception and now we have mouses and touchscreens!
Here is a small list of the most useful events with their mouse, touch, and catch-all variants.
The rule of thumb is that if it has pointer
in the name, it will catch both mouse and touch.
Mouse only | Touch only | Mouse + Touch |
---|---|---|
click |
tap |
pointertap |
mousedown |
touchstart |
pointerdown |
mouseup |
touchend |
pointerup |
mousemove |
touchmove |
pointermove |
The event that fired
When your function gets called you will also receive a parameter, that is all the data that event produced. You can see the full shape of the object here.
I will list now some of the most common properties here now:
event.global
This is the global (stage) position of the interaction(e.target as DisplayObject).toLocal(e.global)
This is the local (object that has the event) position of the interaction. It has an unsafe cast due to the new Event System being a bit paranoid.event.pointerType
This will saymouse
ortouch
Old Magical stuff (pre PixiJS v7)
You might have come across by the fact that if you have a function that is called exactly the same as an event (for example a click
function) and your object is interactive, that function gets called automagically.
That was because there is a line inside the old Interaction Manager's that if it finds that a display object has a method with the same name as an event it calls it.
This was removed on PixiJS' v7 new Federated Events System
--
Keyboard
import { Container, Sprite } from "pixi.js";
export class Scene extends Container {
private clampy: Sprite;
constructor(screenWidth: number, screenHeight: number) {
super();
// Clampy has nothing to do here, but I couldn't left it outside, poor thing
this.clampy = Sprite.from("clampy.png");
this.clampy.anchor.set(0.5);
this.clampy.x = screenWidth / 2;
this.clampy.y = screenHeight / 2;
this.addChild(this.clampy);
// No pixi here, All HTML DOM baby!
document.addEventListener("keydown", this.onKeyDown.bind(this));
document.addEventListener("keyup", this.onKeyUp.bind(this));
}
private onKeyDown(e: KeyboardEvent): void {
console.log("KeyDown event fired!", e);
// Most likely, you will switch on this:
// e.code // if you care about the physical location of the key
// e.key // if you care about the character that the key represents
}
private onKeyUp(e: KeyboardEvent): void {
console.log("KeyUp event fired!", e);
// Most likely, you will switch on this:
// e.code // if you care about the physical location of the key
// e.key // if you care about the character that the key represents
}
}
And here is where PixiJS lets go of our hands and we have to grow up and use the DOM.
Luckily, the DOM was meant to use keyboards and we have two big events: keydown
and keyup
. The bad news is that this detects ALL keypresses, ALL the time.
To solve which key was pressed at any point in time, we have two string properties on the keyboard event: code and key.
key
key
is the easiest one to explain because it is a string that shows what character should be printed into a textbox after the user presses a key. It follows the user keyboard layout and language so if you need the user to write some text, this is the ideal property.
code
code
might be confusing at first, but let me tell you bluntly: Not everybody uses QWERTY keyboards. AZERTY and DVORAK keyboards are a thing (and we are not even getting into right to left keyboards) so if you bind your character jumping to the Z
key you might find out later that not everybody has it in the same place in their keyboard distributions!
For problems like this, code
was born. It represents a physical location in a keyboard so you can confidently ask for the Z
and X
keyboard keys and they will be one next to the other no matter what language it is using.
Consider this a
Keyboard.ts
recipe:
class Keyboard {
public static readonly state: Map<string, boolean>;
public static initialize() {
// The `.bind(this)` here isn't necesary as these functions won't use `this`!
document.addEventListener("keydown", Keyboard.keyDown);
document.addEventListener("keyup", Keyboard.keyUp);
}
private static keyDown(e: KeyboardEvent): void {
Keyboard.state.set(e.code, true)
}
private static keyUp(e: KeyboardEvent): void {
Keyboard.state.set(e.code, false)
}
}
But sometimes we need to know the state of a key: Is it pressed? To do this we need to keep track of this state of every key manually, luckily we can do it with a really simple static class.
We just need to once call the Keyboard.initialize()
method to add the events and then we can ask Keyboard.state.get("ArrowRight")
and if this says true then the key is down!
keyCode, which, keypress are deprecated!
--
Making custom events and the overall syntax
All display objects are event emitters so we can create a sprite to test:
const clampy: Sprite = Sprite.from("clampy.png");
clampy.on("clamp", onClampyClamp, this);
clampy.once("clamp", onClampyClampOnce, this);
// clampy.off("clamp", onClampyClamp); // This will remove the event!
// somewhere, when the time is right... Fire the clamp!
clampy.emit("clamp");
// If you come from c++ this will mess you up: Functions can be declared after you used them.
function onClampyClamp() {
console.log("clampy did clamp!");
}
function onClampyClampOnce() {
console.log("this will only be called once and then removed!");
}
using
.off(...)
can be tricky. If you used.bind(this)
then it will probably not work. That is why there is an extra parameter so you can provide thethis
context to theon(...)
function!
What do we do if we want to be notified when something happens? One way could be setting a boolean flag and looking at it at every update loop, waiting for it to change but this is clumsy and hard to read. Introducing Events!
An event is a way for an object to emit a scream into the air and for some other object to listen and react to this scream.
For this purpose, PixiJS uses EventEmitter3.
The API is made to mirror the one found on node.js
Let's see a quick overview of how EventEmitter3 works:
.on(...)
: Adds an event listener..once(...)
: Adds an event listener that will remove itself after it gets called once..off(...)
: Removes an event listener. (Tricky to use if you use.bind
!).emit(...)
: Emits an event, all listeners for that event will get called..removeAllListeners()
: Removes all event listeners.
Collision detection
Stop touching meeeeeeee
We know how to put things on screen, how to make them move under our control but now we need to know when they happen to be one on top of the other.
To detect this we first need to understand a bit of math on how to know if two rectangles are overlapping, then we will see how to find global rectangles for our PixiJS objects.
PixiJS has the Rectangle
class and the Bounds
class and then PixiJS DisplayObjects have a getBounds()
method but it returns a Rectangle
and not a Bounds
. Please don't think too much about it.
Pure rectangle math
Here you have a snippet to check if two rectangles intersect
// assume `a` and `b` are instances of Rectangle
const rightmostLeft = a.left < b.left ? b.left : a.left;
const leftmostRight = a.right > b.right ? b.right : a.right;
if (leftmostRight <= rightmostLeft)
{
return false;
}
const bottommostTop = a.top < b.top ? b.top : a.top;
const topmostBottom = a.bottom > b.bottom ? b.bottom : a.bottom;
return topmostBottom > bottommostTop;
Ok, first I have to be honest with you, when I said Rectangles I exagerated. We will be working with Axis-aligned bounding boxes (AABB) and that means that the sides of our rectangles will always be parallel to one axis and perpendicular to the other. (In layman terms, no rotated rectangles here.)
With that out of the way let's see the naming, a rectangle has x
, y
, width
, and height
.
They also have:
- top
which is equal to y
- bottom
which is equal to y + height
- left
which is equal to x
- right
which is equal to x + width
In our algorithm we now calculate (please read this slowly and thoroughly):
- rightmostLeft
: Compare the left
value of our two rectangles and pick the rightmost.
- leftmostRight
: Compare the right
value of our two rectangles and pick the leftmost.
- bottommostTop
: Compare the top
value of our two rectangles and pick the bottom-most.
- topmostBottom
: Compare the bottom
value of our two rectangles and pick the topmost.
If you think about it, I now have a new set of left
, right
, top
, and bottom
values.
Here comes the magical part: if these values make sense, that is to say left
is to the left of right
AND top
is above of bottom
our initial rectangles are overlapping.
As soon as one of the pairs doesn't make sense, we know those rectangles can not be overlapping.
DisplayObjects into Rectangles
function checkCollision(objA: DisplayObject, objB: DisplayObject): boolean {
const a = objA.getBounds();
const b = objB.getBounds();
const rightmostLeft = a.left < b.left ? b.left : a.left;
const leftmostRight = a.right > b.right ? b.right : a.right;
if (leftmostRight <= rightmostLeft) {
return false;
}
const bottommostTop = a.top < b.top ? b.top : a.top;
const topmostBottom = a.bottom > b.bottom ? b.bottom : a.bottom;
return topmostBottom > bottommostTop;
}
Ok, now that we know how to check if two rectangles overlap we just need to make rectangles out of PixiJS DisplayObjects, introducing getBounds()
The bounds of a DisplayObject is an axis-aligned bounding box and in the case of containers, this box contains inside all its children.
The magic of this is that it works no matter how far apart are the DisplayObjects in the display tree because the method returns the global bounds of any object and that gives us a shared reference system for any pair of objects.
So, the logic is quite simple, we just need to getBounds()
and then send them into the previous algorithm.
A note on colliding polygons.
For colliding angled rectangles, polygons, and more information about how they collide you can use the separating axis theorem (SAT) that says: Two convex objects do not overlap if there exists a line (called axis) onto which the two objects' projections do not overlap.
However, the algorithm is not as trivial as the AABB one, and getting DisplayObjects to become polygonal shapes adds another layer of complexity.
I do have an implementation for this but it's not elegant and I don't feel confident in giving it for the world to use. One day I might write a more robust implementation and make it free to the public.
Sounding Good
You and the marvelous world of sounds
To play sounds and music in our games we are going to use the PixiJS solution for sound.
Just like its rendering counterpart, PixiJS Sound hides away an implementation meant to work on every browser leveraging the WebAudio API giving us a simple-to-use interface with a lot of power under the hood.
If you want a more in-depth demo you can check the PixiJS Demo website.
Playing a sound
import { Container } from "pixi.js";
import { Sound } from "@pixi/sound";
export class Scene extends Container {
constructor(screenWidth: number, screenHeight: number) {
super();
// Just like everything else, `from()` and then we are good to go
const whistly = Sound.from("whistle.mp3");
whistly.volume = 0.5;
whistly.play();
}
}
If you reached this point making Sprites
you will see the Sound.from()
syntax and feel at home.
You just create a sound object directly from a sound file URL and then you can tweak it and play it!
Some helpful things inside a sound are:
.play()
,.stop()
and.isPlaying
.pause()
,.resume()
and.paused
.volume
and.muted
.loop
.filters
(this is a bit more advanced but you can achieve some really cool effects!)
Recipes
This is where the basics end and while PixiJS still have plenty of classes and utilities I haven't explained yet I think so far I have explained everything you need to build your own game and research further.
That being said... there are some things I've picked up along the way making games: Introducing Recipes.
Recipes is going to be a way of providing you with the solutions to problems I have faced in my game-making career.
These Recipes will have a lot of code, so this column will probably the star of the show...
Recipe: Preloading assets
So far we have seen how to create images and sounds by downloading the asset behind them just as we need to show it to the user. While this is good enough if we want a quick way to make a proof of concept or prototype, it won't be good enough for a project release.
The old and elegant way of doing it is downloading all the assets you are going to need beforehand and storing them in some sort of cache.
However, times change and users now want everything ready right now! so instead of having one big load at the beginning we will aim to have the smaller download possible to make do and keep everything loading in the background and hopefully when the user reaches further into the game our assets will be ready.
To this purpose, PixiJS includes Assets: A system for your resource management needs. A promise-based, background-loading, webworker-using, format-detecting, juggernaut of a system.
In this recipe, we are going to create one of our Scene
to load all the files we declare in a manifest object and then I will teach you how to recover the files from the Assets
cache.
First of all, a primer on Promises.
use
.then(..)
to be notified when that value is ready
somePromise.then((myValue) => {
// This function will be called when "myValue" is ready to go!
});
A promise is something that will eventually have a value for you to use but you can't be sure if that value is ready or not just yet, so you ask nicely to be notified when that happens.
When a Promise has your value, it is said that it resolves your value. For you to use the value you need to give a function to the .then(...)
method.
keep in mind that even resolved promises take a bit of time before executing your then, so take a look at this code...
console.log("before");
alreadyResolvedPromise((value)=>{
console.log("inside the then with my value", value);
});
console.log("after")
will give us: "before" -> "after" -> "inside the then with my value".
Awaiting Promises
Take a look at this very naïve code...
// async functions always return a Promise of whatever they return
async function waitForPromise() : Promise<string>
{
console.log("This starts excecuting");
// As this next line has `await` in it, it will "freeze" the execution here until that magical promise resolves to a value.
const magicalNumber = await someMagicalPromise;
// When the promise is resolved, our code keeps executing
console.log("Wow, that took a while!");
// We return a string with the resolved value... but that took some time, so the entire function actually returns a promise.
return "This will be finally resolved! The Magical number was " + magicalNumber.toString();
}
// We kinda need to call the function, right?
waitForPromise();
To keep code somewhat easier to read, you can use two keywords async
and await
to freeze the code until a promise resolves.
This doesn't really change how Promises work, this is only syntax sugar that will be turned into the regular functions by the browser.
Using Assets
Using
Assets.load(...)
with.then(...)
Assets.load("./clampy.png").then(clampyTexture => {
const clampySprite = Sprite.from(clampyTexture);
});
// Be careful, `clampySprite` doesn't exist out here, only inside those brackets!
Using
Assets.load(...)
withasync
await
async function loadClampy() : Promise<Sprite>
{
const clampyTexture = Assets.load("./clampy.png");
return Sprite.from(clampyTexture);
}
// now calling `loadClampy()` will yield a Clampy sprite promise!
Sometimes we just want to load one single file, quick and dirty. For that purpose we have Assets.load(...)
.
This will return a promise that will solve with the loaded and parsed asset ready to use!
.load(...) vs .get(...)
Assets
has two methods to get your asset from inside it. The safer Assets.load(...)
that will return a promise and the unsafe Assets.get(...)
that will just return your asset... or undefined
.
If you are absolutely sure that your asset has been loaded before and you don't want to deal with the promise that Assets.load(...)
returns, you can use Assets.get(...)
to get your asset directly from the cache, however if you were wrong and your asset wasn't loaded before you will get undefined
.
The files-to-download Manifest.
Let's start with our manifest object. I will call this file
assets.ts
{manifest's entries can have many shapes, this is the one I like the most.}
import type { ResolverManifest } from "pixi.js";
export const manifest:ResolverManifest = {
bundles: [
{
name : "bundleName",
assets:
{
"Clampy the clamp" : "./clampy.png",
"another image" : "./monster.png",
}
},
{
name : "another bundle",
assets:
{
"whistle" : "./whistle.mp3",
}
},
]
}
As javascript is unable to "scan" a directory and just load everything inside we need to add some sort of manifest object that lists all the files that we need to download. Conveniently, Assets
has a format to feed Asset Bundles from a manifest.
An Asset Bundle is just that, a group of assets that make sense for our game to download together, for example, you make an bundle for each screen of your game.
Here we can see we are how we can declare (and export for outside use) an Asset Bundle with the format that Assets
wants it.
Each bundle has a name
field and an object of assets
where the key is the name for each asset and the value is the URL for it. (by starting with ./
we mean "relative to our index.html").
Using our Manifest
This is just a snippet of how to initialize
Assets
. After this, we will see how to make a full loaderScene
and background loading.
// remember the assets manifest we created before? You need to import it here
async function initializeLoader(): Promise<void> // This promise won't return any value, will just take time to finish.
{
// Make sure you don't use Assets before this or you will get a warning and it won't work!
await Assets.init({ manifest: manifest });
// let's extract the bundle ids. This is a bit of js black magic
const bundleIds = manifest.bundles.map(bundle => bundle.name);
// we download ALL our bundles and wait for them
await Assets.loadBundle(bundleIds);
// Code ends when all bundles are loaded!
}
// Remember to call it and remember it returns a promise
initializeLoader().then(() => {
// ALL your assets are ready!
});
This is enough to start downloading assets... but what about progress? and how can we tell when we are done?
Assets
is the global static instance in charge of resolving, downloading and keeping track of all our assets so we never download twice. We initialize with Assets.init(...)
feeding it our manifest and then we call Assets.loadBundle(...)
with all the names of our asset bundles.
Making it look pretty
This is our full code for
LoaderScene.ts
import { Container, Graphics, Assets } from "pixi.js";
import { manifest } from "../assets";
export class LoaderScene extends Container {
// for making our loader graphics...
private loaderBar: Container;
private loaderBarBoder: Graphics;
private loaderBarFill: Graphics;
constructor(screenWidth: number, screenHeight: number) {
super();
// lets make a loader graphic:
const loaderBarWidth = screenWidth * 0.8; // just an auxiliar variable
// the fill of the bar.
this.loaderBarFill = new Graphics();
this.loaderBarFill.beginFill(0x008800, 1)
this.loaderBarFill.drawRect(0, 0, loaderBarWidth, 50);
this.loaderBarFill.endFill();
this.loaderBarFill.scale.x = 0; // we draw the filled bar and with scale we set the %
// The border of the bar.
this.loaderBarBoder = new Graphics();
this.loaderBarBoder.lineStyle(10, 0x0, 1);
this.loaderBarBoder.drawRect(0, 0, loaderBarWidth, 50);
// Now we keep the border and the fill in a container so we can move them together.
this.loaderBar = new Container();
this.loaderBar.addChild(this.loaderBarFill);
this.loaderBar.addChild(this.loaderBarBoder);
//Looks complex but this just centers the bar on screen.
this.loaderBar.position.x = (screenWidth - this.loaderBar.width) / 2;
this.loaderBar.position.y = (screenHeight - this.loaderBar.height) / 2;
this.addChild(this.loaderBar);
// Start loading!
this.initializeLoader().then(() => {
// Remember that constructors can't be async, so we are forced to use .then(...) here!
this.gameLoaded();
})
}
private async initializeLoader(): Promise<void>
{
await Assets.init({ manifest: manifest });
const bundleIds = manifest.bundles.map(bundle => bundle.name);
// The second parameter for `loadBundle` is a function that reports the download progress!
await Assets.loadBundle(bundleIds, this.downloadProgress.bind(this));
}
private downloadProgress(progressRatio: number): void {
// progressRatio goes from 0 to 1, so set it to scale
this.loaderBarFill.scale.x = progressRatio;
}
private gameLoaded(): void {
// Our game finished loading!
// Let's remove our loading bar
this.removeChild(this.loaderBar);
// all your assets are ready! I would probably change to another scene
// ...but you could build your entire game here if you want
// (pls don't)
}
}
Keep in mind that if you have few assets (or a really fast internet connection), you might not see the progress bar at all!
I will not explain how the loading bar was made. If you need to refresh how to put stuff on screen check that chapter again.
You will see that the constructor calls an async
function and is forced to use the .then(...)
method, that is because constructors can never be async
.
You might have realized that we just downloaded all our assets at the same time, at the very beginning of our game. This is has the benefit that once we leave that loading screen we will never need to download anything else ever again, however we forced our user to stay some time waiting and users nowadays don't like waiting.
To remedy this we can take a different approach: we just load the bare minimum needed for the current screen and let the rest download in the background. We will see this in a future recipe
How to easily use your loaded Sprites and Sounds?
Now that we downloaded and safely stored our assets in a cache... how do we use them?
The two basic components we have seen so far are Sprite
and Sound
and we will use them in different ways.
For Sprites, all our textures are stored in a TextureCache somewhere in the PixiJS universe but all we need to know is that we can access that cache by doing Sprite.from(...)
but instead of giving an URL we just give the name we gave our asset in the manifest file. In my example above I could do Sprite.from("Clampy the clamp")
. (If you ever need a Texture
object, you can get it the same way with Texture.from(...)
just remember that Sprites go on screen and Textures hide inside sprites.)
For Sounds, all our sounds are stored in what PixiJS Sound calls sound library. To access it we have to import it as import { sound } from "@pixi/sound";
. That is sound
with a lowercase s. From there you can play it by doing sound.play("name we gave in the manifest");
.
Advanced loading
Ok, we have seen how to load our basic png and mp3 but what about other kinds of assets? What about spritesheets, fonts, levels, and more?
This section will show some of the most common asset types and how to load and use them.
But before we start, a bit of how things will work behind the curtains: Assets, Resolvers, Loader, Cache and Detection. You might never need to know more about these, but I have to mention them.
- Assets is a static instance for
AssetsClass
. It's the entry point and coordinator of the rest of the system. - Resolver is a class that knows how to map assets to URLs. It also knows how to pick the best asset type for your system.
- Loader is the bit that actually downloads your file. It does noting else than going to an URL and getting data from it.
- Cache stores all the related assets for a certain key.
- Detection is how you tell the rest of the system that you can totally handle that format of asset.
Spritesheets
Add this to one of your bundles in
assets.ts
...
name : "bundleName",
assets:
{
...
"you probably wont use this name": "./yourSpritesheetUrl.json",
// Don't add an entry for the .png file! Just make sure it exists next to your json file and it will work.
...
}
...
Then your textures from inside your spritesheet will exist in the cache! Ready to Sprite.from()
Sprite.from("Name from inside your spritesheet");
// or
Texture.from("Name from inside your spritesheet");
A spritesheet (also known as texture atlas) is a single image file with many assets inside next to a text file (in our case a json
file) that explains how to slice that texture to extract all the assets. It is really good for performance and you should try to always use them.
To create a spritesheet you can use paid programs like TexturePacker or free ones like ShoeBox or FreeTexPacker.
If you have problems finding a PixiJS compatible format in the packer of your choice, it might also be called JSON Hash format
PixiJS includes a spritesheet parser so all you need to do is provide the url for the json file. You shouldn't add the URL for .png file but it must be next to the json file!
Fonts
Add your fonts to a bundle in your
assets.ts
...
name : "bundleName",
assets:
{
...
"you probably wont use this name but don't repeat names": "./fonts/open-sans.woff2",
...
}
...
Your fonts will be registered into the system and you can just ask for the Font Name. You can check the font name by opening your font file and checking the name. Also, remember you can make your text style with the PixiJS Textstyle editor.
const customStyleFont: TextStyle = new TextStyle({
fontFamily: "Open Sans Condensed", // Your official font name
});
new Text('Ready to use!', customStyleFont); // Text supports unicode!
In PixiJS v7 fonts are now first class citizens!
To use a custom font your just need to add the font to your manifest, it can be in any format: ttf
, otf
, woff
or woff2
.
Maps from level editors or custom txt, json, xml, etc.
Just add your custom file to your
assets.ts
...
name : "bundleName",
assets:
{
...
"my text": "./myTextFile.txt",
"my json": "./myJsonFile.json",
"my xml": "./myXMLFile.xml",
...
}
...
A good way to see what you got is to
console.log()
// I can use `Assets.get(...)` because I am 100% sure that the assets are loaded!
// When in doubt use `Assets.load(...)`!
console.log(Assets.get("my text"));
console.log(Assets.get("my json"));
console.log(Assets.get("my xml"));
The Assets class will recognize simple text formats like txt
, json
or xml
and will give out a somewhat usable object.
If you would like to parse a particular kind of file you will need to write your own Assets plugin. The best way is to look at the Spritesheet or BitmapFont one and work from there.
Recipe: Scene Manager
In the Splitting Code chapter I explained how to create a Scene
object to try to encapsulate different parts of our object and be able to swap them when needed but I didn't explain how to actually change from one scene to the next.
For this purpose, we are going to create a Manager static global class that wraps the PixiJS Application
object and exposes a simple way to change from one scene to the next.
The Manager Class
Ok, let's write our
Manager.ts
file. This file will store the static class Manager and an Interface for our Scenes
import { Application, DisplayObject } from "pixi.js";
export class Manager {
private constructor() { /*this class is purely static. No constructor to see here*/ }
// Safely store variables for our game
private static app: Application;
private static currentScene: IScene;
// Width and Height are read-only after creation (for now)
private static _width: number;
private static _height: number;
// With getters but not setters, these variables become read-only
public static get width(): number {
return Manager._width;
}
public static get height(): number {
return Manager._height;
}
// Use this function ONCE to start the entire machinery
public static initialize(width: number, height: number, background: number): void {
// store our width and height
Manager._width = width;
Manager._height = height;
// Create our pixi app
Manager.app = new Application<HTMLCanvasElement>({
view: document.getElementById("pixi-canvas") as HTMLCanvasElement,
resolution: window.devicePixelRatio || 1,
autoDensity: true,
backgroundColor: background,
width: width,
height: height
});
// Add the ticker
Manager.app.ticker.add(Manager.update)
}
// Call this function when you want to go to a new scene
public static changeScene(newScene: IScene): void {
// Remove and destroy old scene... if we had one..
if (Manager.currentScene) {
Manager.app.stage.removeChild(Manager.currentScene);
Manager.currentScene.destroy();
}
// Add the new one
Manager.currentScene = newScene;
Manager.app.stage.addChild(Manager.currentScene);
}
// This update will be called by a pixi ticker and tell the scene that a tick happened
private static update(framesPassed: number): void {
// Let the current scene know that we updated it...
// Just for funzies, sanity check that it exists first.
if (Manager.currentScene) {
Manager.currentScene.update(framesPassed);
}
// as I said before, I HATE the "frame passed" approach. I would rather use `Manager.app.ticker.deltaMS`
}
}
// This could have a lot more generic functions that you force all your scenes to have. Update is just an example.
// Also, this could be in its own file...
export interface IScene extends DisplayObject {
update(framesPassed: number): void;
}
Lots to unpack here but let's take it easy.
First of all, see that since I made this a fully static class I never use this
object but instead I refer to everything by using the Manager.
notation. This prevents the need to keep track of the context in this class.
Next, we have a private constructor; that means nobody will be able to do new Manager()
. That is important because our class is fully static and it's meant to be initialized by calling Manager.initialize(...)
and we feed parameters about the screen size and the background color for our game.
The Application
instance and the width and height are safely stored and hidden in private static variables but these last two have getters so we can ask globally what is the size of our game.
The initialize function has one last trick: it adds links from your entire Manager
to a ticker so that our Manager.update()
method gets called. That update will then call the current scene update method. This will allow you to have an update method in your scenes that appears to be called magically.
Then we have the Manager.changeScene(...)
method, this is exactly why we started all of this, an easy way to tell our game that the current scene is no longer useful and that we want to change it for a new one.
It's quite simple to see that Manager
removes the old one from the screen, destroys it (actually, destroy()
is a PixiJS method!), and then proceeds to put your new scene directly on screen.
But...
What is a Scene? A miserable little pile of pixels!
Well, it has to be a DisplayObject
of some sort because we need to put it on the screen (remember that Containers
are DisplayObjects) but we also want it to have our custom update(...)
method and to enforce that we create an interface IScene
(I like to begin my interfaces with I
). This makes sure that if an object wants to be a Scene then it must follow the two rules: be some sort of DisplayObject
and have an update(...)
method.
Now, some reruns of your favorite classes!
Look at this buffed up
LoaderScene.ts
that nowimplements IScene
and usesManager
to know its size!
import { Container, Graphics, Assets } from "pixi.js";
import { manifest } from "../assets";
import { IScene, Manager } from "../Manager";
import { GameScene } from "./GameScene";
export class LoaderScene extends Container implements IScene {
// for making our loader graphics...
private loaderBar: Container;
private loaderBarBoder: Graphics;
private loaderBarFill: Graphics;
constructor() {
super();
const loaderBarWidth = Manager.width * 0.8;
this.loaderBarFill = new Graphics();
this.loaderBarFill.beginFill(0x008800, 1)
this.loaderBarFill.drawRect(0, 0, loaderBarWidth, 50);
this.loaderBarFill.endFill();
this.loaderBarFill.scale.x = 0;
this.loaderBarBoder = new Graphics();
this.loaderBarBoder.lineStyle(10, 0x0, 1);
this.loaderBarBoder.drawRect(0, 0, loaderBarWidth, 50);
this.loaderBar = new Container();
this.loaderBar.addChild(this.loaderBarFill);
this.loaderBar.addChild(this.loaderBarBoder);
this.loaderBar.position.x = (Manager.width - this.loaderBar.width) / 2;
this.loaderBar.position.y = (Manager.height - this.loaderBar.height) / 2;
this.addChild(this.loaderBar);
this.initializeLoader().then(() => {
this.gameLoaded();
})
}
private async initializeLoader(): Promise<void>
{
await Assets.init({ manifest: manifest });
const bundleIds = manifest.bundles.map(bundle => bundle.name);
await Assets.loadBundle(bundleIds, this.downloadProgress.bind(this));
}
private downloadProgress(progressRatio: number): void {
this.loaderBarFill.scale.x = progressRatio;
}
private gameLoaded(): void {
// Change scene to the game scene!
Manager.changeScene(new GameScene());
}
public update(framesPassed: number): void {
// To be a scene we must have the update method even if we don't use it.
}
}
In this new LoaderScene
that extends from IScene
there are really only two changes:
* It now extends from IScene
so it must have an update(...)
method even if we don't need it.
* When the loader finishes, it changes the scene to another one! We can finally see Manager.changeScene()
in action!
And this is what the simpler
GameScene.ts
looks like.
import { Container, Sprite } from "pixi.js";
import { IScene, Manager } from "../Manager";
export class GameScene extends Container implements IScene {
private clampy: Sprite;
private clampyVelocity: number;
constructor() {
super();
// Inside assets.ts we have a line that says `"Clampy from assets.ts!": "./clampy.png",`
this.clampy = Sprite.from("Clampy from assets.ts!");
this.clampy.anchor.set(0.5);
this.clampy.x = Manager.width / 2;
this.clampy.y = Manager.height / 2;
this.addChild(this.clampy);
this.clampyVelocity = 5;
}
public update(framesPassed: number): void {
// Lets move clampy!
this.clampy.x += this.clampyVelocity * framesPassed;
if (this.clampy.x > Manager.width) {
this.clampy.x = Manager.width;
this.clampyVelocity = -this.clampyVelocity;
}
if (this.clampy.x < 0) {
this.clampy.x = 0;
this.clampyVelocity = -this.clampyVelocity;
}
}
}
Turning on the entire machine
index.ts
has always been the entry point of our game. Let's use it to start ourManager
and open our first everScene
import { Manager } from './Manager';
import { LoaderScene } from './scenes/LoaderScene';
Manager.initialize(640, 480, 0x6495ed);
// We no longer need to tell the scene the size because we can ask Manager!
const loady: LoaderScene = new LoaderScene();
Manager.changeScene(loady);
So far we have a Manager that can change from one Scene to the next but... how do we start it and go to the first scene ever?
That is what the Manager.initialize(...)
was for and we will call it directly from index.ts
and right after that, we ask it to go to the first scene of our game: the LoaderScene
.
That is all there is to it. You initialize your manager and are ready to rumble from scene to scene!
Recipe: Resize your game
By now you probably have realized that all the examples so far have the game in the top-left corner of the screen, this is because we never bothered to move it or change its size: Let's make it fill the screen.
There are two ways to fill the screen that I like to call letterbox and responsive.
In letterbox you scale your entire game and add black bars to the side when the aspect ratio doesn't match, this is the easier approach and is good enough for small games that will be embedded in an iframe of another website. By using this method the resize is done entirely by the browser with some css trickery so you don't have to add any logic inside your game.
In responsive you make your stage
grow to fill the entire screen and you have to resize and rearrange your game elements to make your game take advantage of all the screen space available, this is almost is mandatory if you are trying to make a game that looks and feel native on every device (mostly smartphones). However, this approach is harder to implement as you have to always be mindful of the scale of your objects, sizes, and positions of the elements of your game.
To ask the browser what is the current screen size we will use a small catch-all piece of code. This is to make sure we get the correct measurement no matter what web browser the user has.
const screenWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0);
const screenHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0);
In any case, we will know the initial screen size (and if the screen changed the size) by using what the browser provides us.
Letterbox scale
Here you have a modified
Manager.ts
class that listens for a resize event and uses css to fix the size of the game.
export class Manager {
private constructor() { }
private static app: Application;
private static currentScene: IScene;
private static _width: number;
private static _height: number;
public static get width(): number {
return Manager._width;
}
public static get height(): number {
return Manager._height;
}
public static initialize(width: number, height: number, background: number): void {
Manager._width = width;
Manager._height = height;
Manager.app = new Application<HTMLCanvasElement>({
view: document.getElementById("pixi-canvas") as HTMLCanvasElement,
resolution: window.devicePixelRatio || 1,
autoDensity: true,
backgroundColor: background,
width: width,
height: height
});
Manager.app.ticker.add(Manager.update)
// listen for the browser telling us that the screen size changed
window.addEventListener("resize", Manager.resize);
// call it manually once so we are sure we are the correct size after starting
Manager.resize();
}
public static resize(): void {
// current screen size
const screenWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0);
const screenHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0);
// uniform scale for our game
const scale = Math.min(screenWidth / Manager.width, screenHeight / Manager.height);
// the "uniformly englarged" size for our game
const enlargedWidth = Math.floor(scale * Manager.width);
const enlargedHeight = Math.floor(scale * Manager.height);
// margins for centering our game
const horizontalMargin = (screenWidth - enlargedWidth) / 2;
const verticalMargin = (screenHeight - enlargedHeight) / 2;
// now we use css trickery to set the sizes and margins
Manager.app.view.style.width = `${enlargedWidth}px`;
Manager.app.view.style.height = `${enlargedHeight}px`;
Manager.app.view.style.marginLeft = Manager.app.view.style.marginRight = `${horizontalMargin}px`;
Manager.app.view.style.marginTop = Manager.app.view.style.marginBottom = `${verticalMargin}px`;
}
/* More code of your Manager.ts like `changeScene` and `update`*/
}
If you are not using a Manager you need to focus on the
window.addEventListener(...)
and theresize()
method.
Letterbox scale implies two things: Making our game as big as possible (while still fitting on screen) and then centering it on the screen (letting black bars appear on the edges where the ratio doesn't match).
It is very important that when we make it as big as possible we don't stretch it and make it look funky, to make sure of this we find the enlargement factor and increase our width and height by multiplying this factor.
To get a factor you just divide the size of the screen by the size of your game but since you have width and height you end up with two factors. To make sure our game doesn't bleed out of the screen and fits inside we pick the smaller of the two factors we found and we multiply our width and height by it.
After we have our englarged size by multiplying by a factor, we calculate the difference between that size and the size of the screen and split it evenly in the margins so our game ends up centered.
Responsive Scale
Here you have a modified
Manager.ts
class that listens for a resize event and notifies the current scene of the new size.
export class Manager {
private constructor() { }
private static app: Application;
private static currentScene: IScene;
// We no longer need to store width and height since now it is literally the size of the screen.
// We just modify our getters
public static get width(): number {
return Math.max(document.documentElement.clientWidth, window.innerWidth || 0);
}
public static get height(): number {
return Math.max(document.documentElement.clientHeight, window.innerHeight || 0);
}
public static initialize(background: number): void {
Manager.app = new Application<HTMLCanvasElement>({
view: document.getElementById("pixi-canvas") as HTMLCanvasElement,
resizeTo: window, // This line here handles the actual resize!
resolution: window.devicePixelRatio || 1,
autoDensity: true,
backgroundColor: background,
});
Manager.app.ticker.add(Manager.update)
// listen for the browser telling us that the screen size changed
window.addEventListener("resize", Manager.resize);
}
public static resize(): void {
// if we have a scene, we let it know that a resize happened!
if (Manager.currentScene) {
Manager.currentScene.resize(Manager.width, Manager.height);
}
}
/* More code of your Manager.ts like `changeScene` and `update`*/
}
export interface IScene extends DisplayObject {
update(framesPassed: number): void;
// we added the resize method to the interface
resize(screenWidth: number, screenHeight: number): void;
}
If you are not using a Manager you need to focus on the
resizeTo: window,
line in the constructor ofApplication
.
To turn on Responsive resize is actually just one line of code: you add resizeTo: window,
to your PixiJS Application
object and it will work however your game won't realize that now has to move and fill the new space (or fit inside a smaller space).
To make this task a bit less hard, we need to know when the game changed size and what is that new size.
To do this, we will listen for the resize
event, update our Manager.width
and Manager.height
variables, and let the current scene know that a change in size occurred. To let the scene know we are going to add a function to our IScene
interface so all scenes must now have the resize(...)
method.
After that... you are on your own: You need to write your own code inside each scene resize(...)
method to make sure every single object in your game reacts accordingly to your new screen size. Good luck.
Resolution (Device Pixel Ratio)
new Application<HTMLCanvasElement>({
view: document.getElementById("pixi-canvas") as HTMLCanvasElement,
resolution: window.devicePixelRatio || 1, // This bad boy right here...
autoDensity: true, // and his friend
backgroundColor: background,
width: width,
height: height
});
The keen-eyed of you might have noticed that when we create our Application
object we have a line that says resolution: window.devicePixelRatio || 1
but what does that mean?
The standard for screens was set to 96 dpi (dots per inch) for a long time and we were happy until the Apple nation attacked with their Retina Display that had twice as many dots per inch, thus if devicePixelRatio
was equal to 1 it meant 96 dpi and if it was equal to 2 it was Retina display.
And then the world kept moving forward, adding more and more dots per inch, in seemingly random amounts so devicePixelRatio
started reporting decimal numbers left and right: everything as a factor of that original 96 dpi.
There is the complementary line that says autoDensity: true
and that is meant for the InteractionManager
. It makes it so that DOM and CSS coordinates (and thus, touch and mouse coordinates) match your global pixel units.
By letting feeding the Apllication
constructor the devicePixelRatio
we render our game in a native resolution for the displays dpi, resulting in a sharper image on devices that have more than 96 dpi but at the cost of some performance since we are effectively supersampling every pixel.
If a particular device (most likely an iOS device) isn't strong enough to render all the pixels of the native resoluton you can always force the resolution to 96 dpi with resolution: 1
and get an image that might be blurry but you get a non-despicable performance boost.
Recipe: Lazy loading assets
Back in the day, people had patience to wait for a game or webpage to fully load before using it, but now users are a lot more impatient and want to have everything ready right now!
To achieve this, the only solution is to have faster and faster internet connections but even that has a limit, so what can we do? We fake it.
By downloading just enough assets so that we can build the screen needed right now we can hope to bamboozle the user on a single screen while we keep loading the rest of the assets in the background and, hopefully, by the time they want to move on to the next screen we already have the assets.
What if we fail to bamboozle the user long enough and they try to advance faster than what our assets download? Well, they will have to wait. It will be up to you to create and show them an interstitial loading thingy
Initialize Early
delete your
LoaderScene
and make some changes to theManager.initialize()
import { Assets } from "pixi.js";
import { manifest } from "../assets";
export class Manager {
// ...
// This is a promise that will resolve when Assets has been initialized.
// Promise<unknown> means this is a promise but we don't care what value it resolves to, only that it resolves.
private static initializeAssetsPromise: Promise<unknown>;
// ...
public static initialize(width: number, height: number, background: number): void {
// We store it to be sure we can use Assets later on
Manager.initializeAssetsPromise = Assets.init({ manifest: manifest });
// Black js magic to extract the bundle names into an array.
const bundleNames = manifest.bundles.map(b => b.name);
// Initialize the assets and then start downloading the bundles in the background
Manager.initializeAssetsPromise.then(() => Assets.backgroundLoadBundle(bundleNames));
// ...
}
// ...
}
Bonus points if you want to make initialize
async
andawait
the initialize promise...
For this setup, we will no longer use the LoaderScene
, so we need to make sure we initialize Assets
in our Manager
constructor before we create any Scene
.
We also set to download everything in the background. Whenever we need to load something for a scene, the background downloads will pause and then resume automagically.
Bundles we depend on
we make a variable to store what bundles we need
export interface IScene extends DisplayObject {
// ...
assetBundles:string[];
// ...
}
make sure you write something inside that array inside your
Scene
!
export class GameScene extends Container implements IScene {
// ...
assetBundles:string[] = ["game", "sounds"];
// ...
}
For this to work, we will need a way for each Scene
to know which asset bundles it needs to show itself. Depending on how we divided our assets we might need just one bundle per scene or we might need many. We will use an array just in case.
We can also set the scene constructor to begin downloading our asset bundles but...
Async constructors aren't a thing!
we can't do this!
export class GameScene extends Container implements IScene {
assetBundles:string[] = ["game", "sounds"];
constructor() {
super();
// This is illegal!
await Assets.loadBundle(this.assetBundles);
// But we need to wait to have assets D:
const clampy = Sprite.from("Clampy the clamp!");
this.addChild(clampy);
}
update(framesPassed: number): void {}
}
We go in a different way, let Manager load your assets and let the scene now.
export interface IScene extends DisplayObject {
// ...
assetBundles: string[];
constructorWithAssets(): void;
// ...
}
And change how Manager changes the scene...
export class Manager {
// ...
public static async changeScene(newScene: IScene): Promise<void> {
// let's make sure our Assets were initialized correctly
await Manager.initializeAssetsPromise;
// Remove and destroy old scene... if we had one..
if (Manager.currentScene) {
Manager.app.stage.removeChild(Manager.currentScene);
Manager.currentScene.destroy();
}
// If you were to show a loading thingy, this will be the place to show it...
// Now, let's start downloading the assets we need and wait for them...
await Assets.loadBundle(newScene.assetBundles);
// If you have shown a loading thingy, this will be the place to hide it...
// when we have assets, we tell that scene
newScene.constructorWithAssets();
// we now store it and show it, as it is completely created
Manager.currentScene = newScene;
Manager.app.stage.addChild(Manager.currentScene);
}
// ...
}
Finally, an example of how an scene should kinda look
export class GameScene extends Container implements IScene {
assetBundles:string[] = ["game", "sounds"];
constructor() {
super();
// We can make anything here as long as we don't need assets!
}
constructorWithAssets(): void {
// Manager will call this when we have all the assets!
const clampy = Sprite.from("Clampy the clamp!");
this.addChild(clampy);
}
update(framesPassed: number): void {}
}
As I said before, async
constructors are not a thing so we can't await
on the construction of a Scene
for our assets. We are forced to use a method to finish the construction of our Scene
. Let's say this is the constructorWithAssets()
method. We will delegate the responsibility of downloading the assets a Scene
needs to the Manager
and make it tell the scene when the assets are downloaded.
It is left as an exercise to the reader to make the interstitial loading thingy in case the user goes too fast and has to wait for more assets to download.
(Or you can cheat and always keep the interstitial loading thingy in the background and it will only be seen when there is no scene covering it)
Older guides
As PixiJS moves forward I will try to keep these guides updated. Sometimes this will mean that I will make small modifications here and there to make sure this guide works for the latest version of PixiJS.
However, some changes will be too big and entire sections will become obsolete and new sections will need to be created.
Below you will find all those sections that got retired.
Recipe: Preloading assets v6
Superseded by the Assets
class in PixiJS v7
So far we have seen how to create images and sounds by downloading the asset behind them just as we need to show it to the user. While this is good enough if we want a quick way to make a proof of concept or prototype, it won't be good enough for a project release.
The elegant way of doing it is downloading all the assets you are going to need beforehand and storing them in some sort of cache. To this purpose, PixiJS includes Loader: An extensible class to allow the download and caching of any file you might need for your game.
In this recipe, we are going to create one of our Scene
to load all the files we declare in a manifest object and then I will teach you how to recover the files from the Loader
cache.
The file to download manifest.
Let's start with our manifest object. I will call this file
assets.ts
export const assets = [
{ name: "Clampy the clamp", url: "./clampy.png" },
{ name: "another image", url: "./monster.png" },
{ name: "whistle", url: "./whistle.mp3" },
]
As javascript is unable to "scan" a directory and just load everything inside we need to add some sort of manifest object that lists all the files that we need to download.
Here we can see we are how we can declare (and export for outside use) an array of objects that will be used by the Loader
.
The name
field is going to be our key to retrieve the downloaded object and the url
field must point to where our asset is going to be located (by starting with ./
we mean "relative to our index.html").
How to use the Loader
This is just a snippet of how to use
Loader
. After this, we will see how to make a full loaderScene
// remember the assets manifest we created before? You need to import it here
Loader.shared.add(assets);
// this will start the load of the files
Loader.shared.load();
// In the future, when the download finishes you will find your entire asset like this
Loader.shared.resources["the name you gave your asset in the manifest"];
// You will probably want `.data` or `.texture` or `.sound` of this object
// however for Pixi objects there is are better ways of creating them...
This is enough to start downloading assets... but what about progress? and how can we tell when we are done?
Like the Ticker
class we saw before (and many other PixiJS classes), Loader
has a shared instance we can access globally and we are going to use that to load our assets and keep them in cache so we can reference them without downloading them each time.
The add(...)
method can take many shapes but we are feeding it our resource manifest we stored in assets.ts
and then begin the download by calling the load()
method.
Making it look pretty
This is our full code for
LoaderScene.ts
import { Container, Graphics, Loader } from "pixi.js";
import { assets } from "../assets";
export class LoaderScene extends Container {
// for making our loader graphics...
private loaderBar: Container;
private loaderBarBoder: Graphics;
private loaderBarFill: Graphics;
constructor(screenWidth: number, screenHeight: number) {
super();
// lets make a loader graphic:
const loaderBarWidth = screenWidth * 0.8; // just an auxiliar variable
// the fill of the bar.
this.loaderBarFill = new Graphics();
this.loaderBarFill.beginFill(0x008800, 1)
this.loaderBarFill.drawRect(0, 0, loaderBarWidth, 50);
this.loaderBarFill.endFill();
this.loaderBarFill.scale.x = 0; // we draw the filled bar and with scale we set the %
// The border of the bar.
this.loaderBarBoder = new Graphics();
this.loaderBarBoder.lineStyle(10, 0x0, 1);
this.loaderBarBoder.drawRect(0, 0, loaderBarWidth, 50);
// Now we keep the border and the fill in a container so we can move them together.
this.loaderBar = new Container();
this.loaderBar.addChild(this.loaderBarFill);
this.loaderBar.addChild(this.loaderBarBoder);
//Looks complex but this just centers the bar on screen.
this.loaderBar.position.x = (screenWidth - this.loaderBar.width) / 2;
this.loaderBar.position.y = (screenHeight - this.loaderBar.height) / 2;
this.addChild(this.loaderBar);
// Now the actual asset loader:
// we add the asset manifest
Loader.shared.add(assets);
// connect the events
Loader.shared.onProgress.add(this.downloadProgress, this);
Loader.shared.onComplete.once(this.gameLoaded, this);
// Start loading!
Loader.shared.load();
}
private downloadProgress(loader: Loader): void {
// Progress goes from 0 to 100 but we are going to use 0 to 1 to set it to scale
const progressRatio = loader.progress / 100;
this.loaderBarFill.scale.x = progressRatio;
}
private gameLoaded(): void {
// Our game finished loading!
// Let's remove our loading bar
this.removeChild(this.loaderBar);
// all your assets are ready! I would probably change to another scene
// ...but you could build your entire game here if you want
// (pls don't)
}
}
Keep in mind that if you have few assets (or a really fast internet connection), you might not see the progress bar at all!
I will not explain how the loading bar was made. If you need to refresh how to put stuff on screen check that chapter again.
Those events don't look exactly like the other events we saw in the Interaction section and that is because Loader uses their own kind of events called signals or minisignals however they work quite similarly to regular events, the big difference is that each signal is only good for one kind of event and that is why we don't have a string explaining what kind of event we need and instead we have two objects onProgress
and onComplete
.
How to easily use your loaded Sprites and Sounds?
Now that we downloaded and safely stored our assets in a cache... how do we use them?
The two basic components we have seen so far are Sprite
and Sound
and we will use them in different ways.
For Sprites, all our textures are stored in a TextureCache somewhere in the PixiJS universe but all we need to know is that we can access that cache by doing Sprite.from(...)
but instead of giving an URL we just give the name we gave our asset in the manifest file. In my example above I could do Sprite.from("Clampy the clamp")
. (If you ever need a Texture
object, you can get it the same way with Texture.from(...)
just remember that Sprites go on screen and Textures hide inside sprites.)
For Sounds, all our sounds are stored in what PixiJS Sound calls sound library. To access it we have to import it as import { sound } from "@pixi/sound";
. That is sound
with a lowercase s. From there you can play it by doing sound.play("name we gave in the manifest");
.
Advanced loading
Ok, we have seen how to load our basic png and mp3 but what about other kinds of assets? What about spritesheets, fonts, levels, and more?
This section will show some of the most common asset types and how to load and use them.
But before we start, a bit of how things will work behind the curtains: Loader plugins.
The PixiJS Loader extends Resource Loader by Chad Engler and includes some plugins for downloading and parsing images, and spritesheets however for other types we will need custom plugins (e.g. for WebFonts) or the plugin will come bundled with a library (e.g. PixiSound includes a loader plugin)
Spritesheets
Add this to your
assets.ts
array...
{ name: "you wont use this name", url: "./yourSpritesheetUrl.json" }
// Don't add an entry for the .png file! Just make sure it exists next to your json file and it will work.
Then your textures from inside your spritesheet will exist in the cache! Ready to Sprite.from()
Sprite.from("Name from inside your spritesheet");
// or
Texture.from("Name from inside your spritesheet");
A spritesheet (also known as texture atlas) is a single image file with many assets inside next to a text file (in our case a json
file) that explains how to slice that texture to extract all the assets. It is really good for performance and you should try to always use them.
To create a spritesheet you can use paid programs like TexturePacker or free ones like ShoeBox or FreeTexPacker.
If you have problems finding a PixiJS compatible format in the packer of your choice, it might also be called JSON Hash format
PixiJS includes a spritesheet parser so all you need to do is provide the url for the json file. You shouldn't add the URL for .png file but it must be next to the json file!
Fonts
Install PixiJS Webfont Loader by running
npm i pixi-webfont-loader
and then proceed to run this code before using your loader
// Add before using Loader!!!
Loader.registerPlugin(WebfontLoaderPlugin);
// Now you can start using loader like Loader.shared.add(assets);
Add your .css fonts to your
assets.ts
array
{ name: "you wont use this name", url: "./fonts/stylesheet.css" }
Your fonts will be registered into the system and you can just ask for the Font Name. You can check the font name by opening your
.css
with a text editor and checking thefont-family
inside. Also, remember you can make your text style with the PixiJS Textstyle editor.
const customStyleFont: TextStyle = new TextStyle({
fontFamily: "Open Sans Condensed", // name from inside the .css file
});
new Text('Ready to use!', customStyleFont); // Text supports unicode!
When I showed you text examples I used system fonts on purpose, but what if you want to use your custom font? or a google font?
In the web world, there are many font formats: ttf
, otf
, woff
, woff2
; but how do we make sure we have the right font format for each browser? Simple, WE HAVE THEM ALL!
An easy way is to get the font file you want to use and convert it using a service like Transfonter. That will give you a .css
file and all the formats required.
Another way is to go to Google Fonts and download a Webfont from there.
To load our font's css
file we are going to need a Loader plugin: PixiJS Webfont Loader.
You can install the plugin by running npm i pixi-webfont-loader
in a console in your project and then inside your code you must register the plugin.
Maps from level editors or custom txt, json, xml, etc.
Just add your custom file to your
assets.ts
{ name: "my text", url: "./myTextFile.txt" },
{ name: "my json", url: "./myJsonFile.json" },
{ name: "my xml", url: "./myXMLFile.xml" },
A good way to see what you got is to
console.log()
thedata
console.log(Loader.shared.resources["my text"].data);
console.log(Loader.shared.resources["my json"].data);
console.log(Loader.shared.resources["my xml"].data);
The Loader class will recognize simple text formats like txt
, json
or xml
and will give out a somewhat usable object inside the data
object of the resource.
If you would like to parse a particular kind of file you will need to write your own Loader plugin. I might write a tutorial for that in the future but for now, you can read this one by Matt Karl.