Space shooter game in JavaScript using Pixi.js
In another article, I showed you how to create a platformer game in JavaScript using the Pixi.js and Matter.js libraries. I also wrote another article showing how to create a clone of Doodle Jump. These articles used a skeletal project structure discussed in this one.
Let’s continue our journey and create a space shooter game using Pixi.js. We start with the same project skeleton code from before. You may clone it from this github repository.
Here’s a preview of the game before implementing shooting. You can play it live here.
The plan
We’ll create a game in which you’ll see a star field scrolling in the background. On the left will be a space craft which serves as our hero. You’d use the up and down arrow keys to move the space ship up and down the screen. From the right enemy space ships will appear at random intervals at random heights on the screen. Each fighter will move across the screen at a constant speed and disappear on the other side, unless it is destroyed en-route. You may shoot at them by pressing the space bar on your keyboard. If our ammo hits an enemy fighter, it gets destroyed. If an enemy fighter hits the hero, it dies. We’ll play an engine sound in the background. There’ll be a sound effect when a space craft is destroyed, as well as when a shot is fired.
Project template
First, download or clone the project template from this github respository. In short, the following directories are important:
webpack
: This directory contains the webpack build configuration files.src/scripts/system
: This directory contains the game engine, i.e., code that is common to all games created with this skeleton.src/scripts/game
: This directory contains the game-specific code.
Install the physics engine
In this game, we don’t need gravity and friction, but I felt lazy and installed matter-js, anyway. Modify the package.json
file and add the required dependency.
{
// Other contents of package.json
"dependencies": {
// Other dependencies
"matter-js": "^0.19.0",
},
}
Now, run the command npm install
in the top-level directory to install Matter.js.
The assets
Download free game assets (the sprite images and sounds) from open game art or any other similar website. I recommed using .png
files for images and .mp3
files for the sounds. You may create your own sprites in a Microsoft Paint like program, if you want. You’ll need:
- A star field image
- A couple of images for the hero and the enemy space craft
- Accompanying series of images of engine exhaust
- A series of images for an explosion
- A series of images for a shot being fired
Place these files in the src/sprites
directory. Make sure that the star field image is named bg.png
and it overwrites the file with the same name already in the project src/sprites
directory. Similarly, name the hero space craft image hero.png
, and the enemy space craft fighter.png
.
At this point, if you run the game by typing npm start
in the top-level game directory, you should already see a scrolling star field on the screen. Yayyyy!
Displaying the hero
Create a file name Hero.js
in the src/scripts/game
directory. We start with the following code:
import * as PIXI from "pixi.js";
import { App } from '../system/App';
import * as Matter from 'matter-js'
export class Hero {
constructor() {
this.location = {x: 20, y: Math.floor(window.innerHeight / 2)}
this.createSprite();
this.createBody()
}
createSprite() {
this.container = new PIXI.Container()
this.container.x = this.location.x
this.container.y = this.location.y
this.sprite = new PIXI.AnimatedSprite([
App.res("exhaust1"),
App.res("exhaust2"),
App.res("exhaust3"),
App.res("exhaust4")
]);
this.sprite.loop = true;
this.sprite.animationSpeed = 0.1;
this.sprite.play();
this.shipSprite = new PIXI.Sprite(App.res('hero1'))
this.container.addChild(this.sprite, this.shipSprite)
}
createBody() {
this.body = Matter.Bodies.rectangle(this.container.x + this.shipSprite.width / 2, this.container.y, this.shipSprite.width, this.shipSprite.height, {friction: 0})
Matter.Composite.add(App.physics.world, this.body)
this.body.gameHero = this
}
}
We define and initialize the hero’s location in the constructor method. We want to set the hero 20 pixels from the left, and vertically centred on the screen. We define two methods, one to create the visual on the screen and the other to create the Body
of the hero that the Matter-js physics engine will use for collision detection.
We need to display an animated engine exhaust as well as the space craft. The Pixi.js Container
object is a handy way to group together such related sprites so that they are placed on the screen and animated together. In the createSprite()
method, we create the Container
object. The location
property set for the Container
is for its top-left corner, with x values increasing to the right and y values downward. As we’ll see shortly, the location of the sprites within the Container
are also for their top-left corners relative to the Container
’s top-left corner.
My series of exhaust images were four in number. To show the exhaust animated on the screen is the job for AnimatedSprite
class in Pixi.js. The constructor for this class accepts an array containing the sprite Texture
objects in the order in which they should be displayed. To obtain the Texture
objects, we query the Application
class instance from our project skeleton using its res()
method which is short for resource. In short, App.res('exhaust1')
returns the Texture
corresponding to a file named exhaust.png
in the src/sprites
directory. We store the created AnimatedSprite
object in the object attribute named this.sprite
. The this.sprite.loop = true
indicates to Pixi.js that we want this sequence of images to be animated continuously in a loop. We set the animation speed for this sprite and ask Pixi.js to start playing the animation right away.
Meanwhile, we create a Sprite
object for the space ship and store it in the object attribute this.shipSprite
. We add the AnimatedSprite
object for the exhaust and the Sprite
object for the ship to the Container
. Next, we position the exhaust and the ship sprites location within the container.
If we don’t set any x
or y
properties for these sprites, they’ll both be positioned with their top-left corners overlapping the Container
’s top-left corner. Since the shipSprite
was added later to the Container
, and it is bigger than the exhaust, it will occlude the exhaust. Furthermore, putting the Container
with its top-left corner at the vertical center of the screen doesn’t exactly center the ship vertically on the screen. Let’s solve both of these problems one by one.
The following figure shows the first problem and its solution.
The ship is facing to the right, and the exhaust is supposed to be to the left of the ship. The left edge of the Container
is where x is 0. Since that’s where the exhaust is supposed to start, we set this.sprite.x
to 0. The ship image starts where the exhaust ends, so we set this.shipSprite.x
to this.sprite.width
. This solves the occlusion problem. The code modification is shown below.
import * as PIXI from "pixi.js";
import { App } from '../system/App';
import * as Matter from 'matter-js'
export class Hero {
/* Other code */
createSprite() {
/* Other code */
this.shipSprite.x = this.sprite.width
}
}
Now, let’s try to center the exhaust and the ship vertically on the screen. The following figure shows the problem and its solution.
We’d like the exhaust to be vertically in the centre of the Container
, because my hero image had the exhaust in its vertical centre. So, we set this.sprite.y
to -this.sprite.height / 2
, i.e., half of the image’s height. This displaces the exhaust image a few pixels upward. The value for this.shipSprite.y
is similar to the corresponding setting for the exhaust. Finally, you’ll notice that we add the two sprites to the Container
object. The code modification is shown below.
import * as PIXI from "pixi.js";
import { App } from '../system/App';
import * as Matter from 'matter-js'
export class Hero {
constructor() {
this.location = {x: 20, y: Math.floor(window.innerHeight / 2)}
this.createSprite();
this.createBody()
}
createSprite() {
/* Other code */
this.sprite.x = 0
this.sprite.y = -this.sprite.height / 2
this.shipSprite.x = this.sprite.width
this.shipSprite.y = -this.shipSprite.height / 2
}
}
Now, we need to modify the GameScene
class in src/scripts/GameScene.js
to add the hero to the default game scene.
import { Background } from "./Background";
import { Scene } from '../system/Scene';
import { Hero } from "./Hero";
export class GameScene extends Scene {
create() {
this.createBackground();
this.createHero();
}
createBackground() {
this.bg = new Background();
this.container.addChild(this.bg.container);
}
update(dt) {
super.update(dt)
this.bg.update(dt.deltaTime);
this.interval += dt.deltaTime
}
createHero() {
this.hero = new Hero();
this.container.addChild(this.hero.container);
}
}
We import the Hero
class. We define a createHero()
method and call it from the create()
method. In the createHero
method, we instantiate a Hero
object and store it in an object attribute. We add this object to the GameScene
object’s Container
. Our hero is hereby part of the game scene. Everything else is unchanged.
If you run the game at this point, by issuing the npm start
command, you should see the hero spacecraft and the exhaust animated in the centre of the screen near the left edge. Great!
Engine sound
Let’s play the engine sound in the background. We can start playing the sound when we create the hero. We import the @pixi/sound
module add a single line of code to GameScene
.
import { Background } from "./Background";
import { Scene } from '../system/Scene';
import { Hero } from "./Hero";
import {sound} from '@pixi/sound'
export class GameScene extends Scene {
/* Other code*/
createHero() {
this.hero = new Hero();
this.container.addChild(this.hero.container);
this.engineSound = sound.play('engine', {loop:true})
}
}
We use the sound.play() method from the @pixi/sound
module with engine
as the first argument, and it picks up the engine.mp3
file from the src/sprites
directory and plays it. The second argument is an object with the loop
key set to true
to indicate that we want this sound to played continuously in a loop. Now, if you run the game, you’ll hear the engine sound in the background.
Enabling hero movement
Now, let’s allow the player to move the hero up and down the screen with the up and down arrow keys.
First, we modify src/scripts/system/Tools.js
to include the keyboard event handler registration. We’ll keep the code generic so that it can be used to register up and down key event handlers for any key on the keyboard. This generic function (source: kittykatattach) accepts the name of a key on the keyboard and returns an object with configured up and down key event handlers.
export class Tools {
/* Other code */
static keyboard(value) {
const key = {};
key.value = value;
key.isDown = false;
key.isUp = true;
key.press = undefined;
key.release = undefined;
//The `downHandler`
key.downHandler = (event) => {
if (event.key === key.value) {
if (key.isUp && key.press) {
key.press();
}
key.isDown = true;
key.isUp = false;
event.preventDefault();
}
};
//The `upHandler`
key.upHandler = (event) => {
if (event.key === key.value) {
if (key.isDown && key.release) {
key.release();
}
key.isDown = false;
key.isUp = true;
event.preventDefault();
}
};
//Attach event listeners
const downListener = key.downHandler.bind(key);
const upListener = key.upHandler.bind(key);
window.addEventListener("keydown", downListener, false);
window.addEventListener("keyup", upListener, false);
// Detach event listeners
key.unsubscribe = () => {
window.removeEventListener("keydown", downListener);
window.removeEventListener("keyup", upListener);
};
return key;
}
}
We create a key
object and set its value
property equal to the key passed as argument. We maintain the key state in isDown
and isUp
properties. By default no key is pressed on the keyboard so we initialize isUp
to true
and isDown
to false
. The press
property will hold the key down event handler, while the release
property will hold the key up event handler. We initialize these to undefined
at first. The calling function can set these properties later. We define the downHalnder()
and upHandler()
methods to check, in the case of a keyboard down or up event, respectively, if the key released or pressed, respectively, is the key for which this object is configured. If so, we modify the isUp
and isDown
states. Also, we invoked the key press or release handlers configured by the calling function. We use the addEventListener()
method to register the two event handlers. We define an unsubscribe()
method to allow the calling function to unsubscribe from the key events. This can be useful when you want to disable key response in the game while you display a splash screen, for example.
Now, we need to use this to register the up and down arrow key event handlers so that we can move our hero space ship on the screen. We modify the GameScene
class.
import { Background } from "./Background";
import { Scene } from '../system/Scene';
import { Hero } from "./Hero";
import {sound} from '@pixi/sound'
import * as Tools from "../system/Tools";
export class GameScene extends Scene {
/* Other code */
createHero() {
this.hero = new Hero();
this.container.addChild(this.hero.container);
this.up = Tools.Tools.keyboard('ArrowUp')
this.up.press = this.hero.moveUp.bind(this.hero)
this.up.release = this.hero.straighten.bind(this.hero)
this.down = Tools.Tools.keyboard('ArrowDown')
this.down.press = this.hero.moveDown.bind(this.hero)
this.down.release = this.hero.straighten.bind(this.hero)
this.engineSound = sound.play('engine', {loop:true})
}
We import the Tools.js
file near the top of the file. In the createHero()
method, we call the keyboard()
method with ArrowUp
and ArrowDown
as arguments. We store the objects returned by these calls in the up
and down
variables. We set the key up and key down event handlers with these objects to point to methods of the Hero
class. Now, let’s define these methods, namely, moveUp()
, moveDown()
, and straighten()
.
export class Hero {
constructor() {
this.velocity = App.config.hero.velocity
this.update = this.update.bind(this)
App.app.ticker.add(this.update)
/* Other code */
}
/* Other code */
moveUp() {
if (this.dy != 1) {
this.dy = 1
}
}
moveDown() {
if (this.dy != -1) {
this.dy = -1
}
}
straighten() {
this.dy = 0
}
update(dt) {
const increment = (this.dy * this.velocity * dt.deltaTime)
if (!this.shipSprite || (this.dy === 1 && this.body.position.y - this.shipSprite.height / 2 <= -increment)) {
return
}
if (!this.shipSprite || (this.dy === -1 && window.innerHeight - this.body.position.y - this.shipSprite.height / 2 <= -increment)) {
return
}
if (this.sprite) {
Matter.Body.setPosition(this.body, {x: this.body.position.x, y: this.body.position.y - increment})
this.container.y = this.body.position.y
}
}
}
In the constructor, we load the velocity
property from the Config
object. We’ll define this shortly. Based on this, we compute a dy
property, which can be +1, 0, or -1. If it is 0, the space craft doesn’t move vertically. If it is +1, the space craft should move up, or if it is -1, it should move down. The moveUp()
, moveDown()
, and straighten()
methods simply set the value of the dy
property. We register an update()
method with the Pixi.js Ticker
object so that it is invoked periodically. In the update()
method, we examine the value of dy
and move the space ship Container
up or down on the screen.
The update()
method is called over and over, and we receive an argument that holds the time elapsed since the last invocation. We use this, the ship’s velocity, and the direction dy
to estimate the distance that the ship should travel in number of pixels. Then, if the ship is to move upward, we make check if it has reached the top edge of the screen. If that is so, then it doesn’t matter if the player is holding the up arrow key, the ship shouldn’t move upward. Thus, we return from the function without changing anything. Similarly, we check if the ship has already reached the bottom edge. If none of these conditions is true, we change the location of the sprite
property, as well as the corresponding Body
object in the Matter-js world.
Finally, let’s modify the Config object in src/scripts/game/Config.js
.
export const Config = {
/* other code */
hero: {
velocity: 2
}
}
Now, if you run the game, you should be able to move the space ship up and down with the up and down arrow keys.
Displaying an enemy
Displaying the enemy is similar to displaying the arrow. The differences are:
- The enemy doesn’t need keyboard control
- The enemy moves across the screen
- The enemy should appear at a random offset from the top of the screen
Let’s define that class in src/scripts/game/Fighter.js
.
import * as PIXI from "pixi.js";
import { App } from '../system/App';
import * as Matter from 'matter-js'
export class Fighter {
constructor(x, y, velocity) {
this.location = {x: x, y: y}
this.velocity = velocity
this.createSprite();
this.createBody()
this.dy = 0
}
createSprite() {
this.container = new PIXI.Container()
this.sprite = new PIXI.AnimatedSprite([
App.res("exhaust1"),
App.res("exhaust2"),
App.res("exhaust3"),
App.res("exhaust4")
]);
this.shipSprite = new PIXI.Sprite(App.res('Ship1'))
this.sprite.loop = true;
this.sprite.animationSpeed = 0.1;
this.sprite.play();
this.container.addChild(this.sprite, this.shipSprite)
this.container.x = this.location.x
this.container.y = this.location.y
this.shipSprite.y = -this.shipSprite.height/2
this.sprite.x = this.shipSprite.width
this.sprite.y = -this.shipSprite.height/2
}
createBody() {
this.body = Matter.Bodies.rectangle(this.container.x + this.container.width / 2, this.container.y, this.container.width, this.container.height, {friction: 0})
Matter.Composite.add(App.physics.world, this.body)
this.body.gameFighter = this
}
move(dt) {
const increment = (this.velocity * dt.deltaTime)
if(this.body) {
if (this.body.position.x < -this.container.width) {
App.app.ticker.remove(this.update, this)
Matter.Composite.remove(App.physics.world, this.body)
this.shipSprite.emit("gone")
this.container.destroy()
this.body = null
}
else {
this.container.x -= increment
Matter.Body.setPosition(this.body, {x: this.body.position.x - increment, y: this.body.position.y})
this.container.x = this.body.position.x - this.container.width / 2
}
}
}
}
It is quite similar to the Hero
class. The positioning in the createSprite()
is different because the enemy ship moves right to left and the exhaust is to be on the right, as opposed to the left in case of the hero.
We have a move()
method which creates the motion across the screen. We’ll need to call it periodically, somehow. We’ll get to that in a bit. But, we rely on the dt
argument which we used in the Hero
movements, and calculate the increment in the x position of the ship based on its previous location, time elapsed since the last refresh, and the velocity. Again, we move both the Matter.js Body
as well as the sprite. We check if the fighter has scrolled across the screen. If so, we remove it from the physics engine because this fighter no longer plays a role in the game. We also emit a gone
message, which we’ll handle in a bit.
We need to get the Fighter
displayed through the GameScene
class. But, rather than show one enemy ship, let’s create another class that manages a the display and movement of a bunch of enemy ships. We create a class in src/scripts/game/Fighters.js
.
import * as PIXI from "pixi.js";
import { App } from "../system/App";
import { Fighter } from "./Fighter";
import * as Matter from 'matter-js'
export class Fighters {
constructor() {
this.fighters = [];
this.container = new PIXI.Container();
this.velocity = App.config.fighter.velocity
this.numChannels = 9
this.channelWidth = Math.floor(window.innerHeight/this.numChannels)
this.createFighter(this.getRandomData(), this.velocity)
this.interval = 0
this.nextSpacing = Math.floor(Math.random()*( App.config.fighter.maximumSpacing - App.config.fighter.minimumSpacing)) + App.config.fighter.minimumSpacing
}
createFighter(data) {
const fighter = new Fighter(data.x, data.y, this.velocity);
this.current = fighter
this.container.addChild(fighter.container);
this.fighters.push(fighter)
fighter.shipSprite.once('gone', (f) => {
const index = this.fighters.findIndex( (f) => f.shipSprite === fighter.shipSprite)
if (index !== -1) {
this.fighters.splice(index, 1)
}
})
}
getRandomData() {
const channel = Math.floor(Math.random() * this.numChannels - Math.floor(this.numChannels / 2))
const location = {x: window.innerWidth - 20, y: window.innerHeight / 2 + channel * this.channelWidth}
return location
}
update(dt) {
this.interval += dt.deltaTime
if (this.interval > this.nextSpacing) {
this.createFighter(this.getRandomData(), this.velocity);
this.interval = 0
this.nextSpacing = Math.floor(Math.random()*( App.config.fighter.maximumSpacing - App.config.fighter.minimumSpacing)) + App.config.fighter.minimumSpacing
}
this.fighters.forEach(fighter => fighter.move(dt));
}
}
In the constructor, we create an empty fighters
array. As we create enemy fighters, we’ll add them to this array, and we can remove them from this array once they are destroyed or scroll out of view. We create a Pixi.js Container
object to hold the fighter sprites. We load the enemy fighter velocity from the configuration file. We’ll define this attribute in it, shortly.
I want the enemy fighters to travel straight in different lanes or channels. I define the number of channels as a constant set to 9. I calculate the height of each channel by dividing the screen height by the number of channels.
I call a createFighter()
method to create a single fighter, passing it the ship velocity and some random data, thanks to a getRandomData()
method. The getRandomData()
method generates an initial x axis location and a random y location. The x axis location is set to slightly outside the right edge of the screen, so that the enemy fighter is initially not visible, and as it moves, it will enter into the view. For the y location, we draw a random integer between -4 and 4. Why these limits? It is because we have 9 channels, if one is smack in the vertical centre of the screen, then there should be four channels above the central channel and four below it. So, if we add this integer times the channel height and add it to the y coordinate of the vertical centre of the screen, then we will get the y coordinate of one of the nine channels.
With these x and y coordinates, we create a Fighter
object, and store its reference in the current
attribute. This attribute holds the reference to the “current” enemy fighter, i.e., the one that is the centre of attention for now. We add this object to the Container
and add the corresponding object to the fighters
array. We also subscribe an event handler to listen to the gone
message which a fighter emits once it has scrolled beyond the screen. Once we receive that message, we remove the corresponding fighter from the fighters
array. Otherwise, the fighters
array will keep increasing and slow our game down.
Back in the constructor, we initialize an interval
attribute to 0, and calculate a random amount of time after which the next enemy fighter should appear on the screen. This is a random number between two number minimumSpacing
and maximumSpacing
which we’ll define in the configuration.
We define an update()
method which will be called periodically. In this method, we update the update
attribute to hold the time elapsed since the “current” fighter was spawned. We compare this duration against the random amount of time we had selected to spawn the next fighter, and if it is time to create another fighter, we call the createFighter()
method to do so. It creates a new Fighter
object, adds it to the Container
, sets it as the “current” fighter, and adds the object to the fighters
array. We reset the interval
attribute and pick the time until the next fighter is to be created.
Finally, we iterate over the fighters
array and call the move()
method of the Fighter
objects so that the enemy fighters scroll across the screen. Now, we need to hook this class to something that displays everything on the screen, and periodically triggers the update()
method.
/* Other imports */
import { Fighters } from "./Fighters";
export class GameScene extends Scene {
create() {
/* Other code */
this.createFighters()
}
/* Other code */
update(dt) {
super.update(dt)
this.bg.update(dt.deltaTime);
this.interval += dt.deltaTime
this.fighters.update(dt)
}
/* Other code */
createFighters() {
this.fighters = new Fighters()
this.container.addChild(this.fighters.container)
}
}
We need to import Fighters
to the GameScene
class. Then, from within the create()
method, we call a createFighters()
method, which creates a Fighters
object, adds its Container
to the GameScene
class’ Container
. Finally, from with the update()
method, we call the update()
method of the Fighters
class.
Let’s add the required configuration to Config.js
/* Imports */
export const Config = {
/* Other code */
fighter: {
velocity: 2,
probability: 0.015,
minimumSpacing: 250,
maximumSpacing: 300
}
}
Fantastic! Now, if you run the program, you should see the enemy fighters randomly appear and scroll across the screen. Now, we need to make sure that if the enemy fighter collides with the hero, they both explode.
Collision and explosion
We already create the Body
objects corresponding to the hero and each of the enemy fighters. The physics engine will take care of the collision detection. We just need to listen to these events and handle them appropriately. Since we have access to all the sprites in the GameScene
class, that’s where we’ll handle this.
/* Imports */
export class GameScene extends Scene {
create() {
/* Other code */
this.registerEvents()
}
registerEvents() {
this.boundOnCollisionStart = this.onCollisionStart.bind(this);
Matter.Events.on(App.physics, 'collisionStart', this.boundOnCollisionStart)
}
onCollisionStart(event) {
const colliders = [event.pairs[0].bodyA, event.pairs[0].bodyB]
const hero = colliders.find((body) => body.gameHero)
const fighter = colliders.find((body) => body.gameFighter)
const fighterIndex = colliders.findIndex( (body) => body.gameFighter)
if (hero && fighter) {
this.engineSound.stop('engine')
this.explode(colliders[fighterIndex].gameFighter)
}
}
explode(fighter) {
fighter.explode()
this.hero.explode()
this.up.unsubscribe()
this.down.unsubscribe()
}
}
We call a registerEvents()
method from the create()
method. In the registerEvents
method we register an event listener for the collisionStart
event of the physics engine. We use a bound instance method as the event-listener so that it can correctly access the object attributes using the this
pointer.
In the event-listener, we access the colliders involved in the collision and check if one of them is the hero and the other a fighter. If it is so, we stop the engine sound, and call the explode()
method with the fighter object as argument. We already have the hero object reference stored as this.hero
, so we don’t have to pass it to explode()
. In explode()
, we call the explode()
methods of the hero and the fighter. Finally, we unsubscribe from the up and down arrow key handlers. Let’s define the explode()
method in the Hero
and Fighter
classes.
I placed the explosion sprite images explosion1_1.png
, explosion1_2.png
, …, explosion1_11.png
and an explosion sound explosion.mp3
in the src/sprites
directory. We’ll play the sound and the explosion animation over the hero and fighter sprites in the explode()
method.
/* Imports */
export class Hero {
/* Other code */
explode(fighter) {
sound.play('explosion')
Matter.Composite.remove(App.physics.world, this.body)
App.app.ticker.remove(this.update)
let names = []
for (let i = 1; i < 12 ; i++) {
const name = `Explosion1_${i}`
names.push(App.res(name))
}
this.flameSprite = new PIXI.AnimatedSprite(names);
this.flameSprite.loop = false;
this.flameSprite.animationSpeed = 0.1;
this.flameSprite.play();
this.flameSprite.position.x = this.sprite.width + this.shipSprite.width / 2 - this.flameSprite.width / 2
this.flameSprite.position.y = - this.flameSprite.height / 2
this.container.addChild(this.flameSprite)
App.app.ticker.add(this.halfFlame)
this.flameSprite.onComplete = this.flameGone
}
halfFlame() {
if (this.flameSprite.currentFrame >= 4) {
this.destroy()
App.app.ticker.remove(this.halfFlame)
}
}
flameGone() {
this.flameSprite.destroy()
this.container.destroy()
this.shipSprite.emit("die")
this.sprite.destroy()
this.shipSprite.destroy()
this.sprite = null
this.shipSprite = null
}
destroy() {
if (this.sprite) {
this.sprite.visible = false
this.shipSprite.visible = false
}
}
}
In the explode()
method of class Hero
, we play the explosion sound. We remove the Body of the hero from the physics engine because it has now officially been destroyed. We remove the update()
method from the Ticker
object since we no longer need to refresh the hero on the screen. We create an array, named names
, containing the names of the explosion animation images. We create an AnimatedSprite()
object out of this array of Texture
objects. We set the loop
property to false
because we want the explosion to play just once. We set an animation speed on this object, and play it.
We will add the explosion animation to the Hero
object’s Container
. We want to position the explosion at the centre of the ship. That means that the its x coordinate should be set to clear the exhaust sprite’s width, as well as half of the space ship sprite’s width. But that puts the explosion’s top-left corner x coordination at the centre of the ship. So, the explosion will appear to be closer to the space ship’s front, not the centre. To correct this, we subtract half of the explosion sprite’s width. The y coordinate setting should be obvious by now. We, then, add the explosion sprite to the Container
. The explosion animation is such that it starts small, grows for the first 5 frames, and then starts to reduce until it disappears. When the explosion is at full intensity, I want the space ship and the exhaust images to disappear to indicate that it has disintegrated. For that reason, we register an event listener named halfFlame()
to the Ticker
object. If five frames have played out, this method removes itself from the Ticker
, and calls a destroy()
method. In destroy()
, we make the space ship and exhaust sprites invisible.
Back to the explode()
method. Once the explosion animation has completed playing, its last frame will remain on the screen. But that isn’t what we want. In order to remove that last frame from the screen, we register a callback to the Complete
event of the AnimatedSprite
object. The event handler, named flameGone()
, calls destroy()
on all the assets and emits a die
event off the shipSprite
object. By registering to listen to this event, we can display a game over screen, or restart another round.
/* Imports */
export class GameScene extends Scene {
/* Other code */
createHero() {
/* Other code */
this.hero.shipSprite.once('die', ()=> {
Matter.Events.off(App.physics, 'collisionStart', this.boundOnCollisionStart);
this.hero = null
this.fighters.destroy()
this.fighters = null
App.scenes.start('Game')
})
}
}
In the createHero()
method in GameScene
, we register an anonymous function as the die
event listener. In that function, we turn off the collision detection in the physics engine, set this.hero
to null
to indicate that our hero is gone. We call the destroy()
method on our fighters
object, and set that reference to null
. Finally, we restart the game.
We need to modify the Fighters
class to add the destroy()
method, and we also need to display the explosion in the Fighter
class. The explosion in the Fighter
class can be done exactly how we did it for the Hero
class.
/* Imports */
export class Fighter {
/* Other code */
explode(fighter) {
Matter.Composite.remove(App.physics.world, this.body)
App.app.ticker.remove(this.update)
let names = []
for (let i = 1; i < 12 ; i++) {
const name = `Explosion1_${i}`
names.push(App.res(name))
}
this.flameSprite = new PIXI.AnimatedSprite(names);
this.flameSprite.loop = false;
this.flameSprite.animationSpeed = 0.1;
this.flameSprite.play();
this.flameSprite.position.x = this.shipSprite.width / 2 - this.flameSprite.width / 2
this.flameSprite.position.y = - this.flameSprite.height / 2
this.container.addChild(this.flameSprite)
App.app.ticker.add(this.halfFlame)
this.flameSprite.onComplete = this.flameGone
}
halfFlame() {
if (this.flameSprite.currentFrame >= 4) {
this.destroy()
App.app.ticker.remove(this.halfFlame)
}
}
flameGone() {
this.flameSprite.destroy()
this.container.destroy()
this.sprite.destroy()
this.shipSprite.destroy()
this.shipSprite.emit('gone')
this.sprite = null
this.shipSprite = null
}
destroy() {
if (this.sprite) {
this.sprite.visible = false
this.shipSprite.visible = false
}
}
}
The above modifications should be obvious by now. Note that in the flameGone()
method, we emit a gone
message. We already have an event listener on it in GameScene
which removes the corresponding Fighter
object from the fighters
array. Since the game is to restart, all the other fighters should also be disposed off. That’s why we called this.fighters.destroy()
from the GameScene
class. Let’s implement that method.
/* Imports */
export class Fighters {
/* Other code */
destroy() {
this.fighters.forEach( (fighter) => {
if (fighter.body) {
Matter.Composite.remove(App.physics.world, fighter.body)
fighter.destroy()
}
})
this.container.destroy()
}
}
We remove all the fighter bodies from the physics engine, and call destroy()
on them so that they disappear from the screen. Finally, we destroy the Container
object.
Now, if you run the program, and collide the hero with an enemy fighter, you should see them both explode, and the game will restart.
Shooting
I have a series of 4 shooting images named shot1_1.png
, shot1_2.png
, shot1_3.png
, and shot1_4.png
. I place these in the src/sprites
directory. I’ll hook up the space bar to firing the shots. We start by modifying the GameScene
class to handle space bar key presses.
/* Imports */
export class GameScene extends Scene {
/* Other code */
createHero() {
/* Other code */
this.shoot = Tools.Tools.keyboard(' ')
this.shoot.press = this.shootHandler.bind(this)
}
shootHandler() {
this.hero.initShot()
}
}
We hook up the shootHandler()
method to listen to space bar key presses, and delegate the shot firing to the Hero
class object. We do this because the location of the shot is determined by the Hero
object.
In the Hero class, we define this method as follows.
/* imports */
export class Hero {
constructor() {
/* Other code */
this.shots = []
}
initShot() {
sound.play('laser')
this.shot = new PIXI.AnimatedSprite([App.res('shot1_1'), App.res('shot1_2'), App.res('shot1_3'), App.res('shot1_4')])
this.shot.loop = false
this.shot.animationSpeed = 1
this.shot.play()
this.container.addChild(this.shot)
this.shot.x = this.sprite.width + this.shipSprite.width
this.shot.y = - this.shot.height / 2
}
}
We declare an empty array named shots
to hold all the shots that we fire. In the initShot()
method we play a laser shot sound, and create an AnimatedSprite
based on the shot images. The four images appear like there’s some sort of arc build up eventually culminating in a shot. We want to display this initial build up at the tip of the space ship, before the shot starts traveling towards the right. We play the animation and add the AnimatedSprite
to the Hero
class’ Container
. We position the AnimatedSprite
at the tip of the space ship.
Let’s also unsubscribe from the space bar key handlers when the fighter gets destroyed.
/* Imports */
export class GameScene extends Scene {
/* Other code */
explode(fighter) {
/* Other code */
this.shoot.unsubscribe()
}
}
Now, if you run the program, you should see an animation at the tip of the space ship when you press the space bar. We should now make the shot travel towards the right.
We define a class for the shot.
import * as PIXI from 'pixi.js'
import { App } from '../system/App'
import * as Matter from 'matter-js'
export class Shoot {
constructor(x, y) {
this.container = new PIXI.Container()
this.container.x = x
this.container.y = y
this.velocity = App.config.shot.velocity
this.createShot()
this.createBody()
}
createShot() {
this.sprite = new PIXI.Sprite(App.res('shot1_4'))
this.container.addChild(this.sprite)
}
createBody() {
this.body = Matter.Bodies.rectangle(this.container.x + this.sprite.width / 2, this.container.y, this.sprite.width, this.sprite.height, {friction: 0})
Matter.Composite.add(App.physics.world, this.body)
this.body.gameShot = this
}
update(dt) {
const increment = this.velocity / dt.deltaTime
this.container.x += increment
if (this.sprite) {
if (this.body.position.x > window.innerWidth) {
Matter.Composite.remove(App.physics.world, this.body)
this.sprite.emit('beyond')
this.sprite.destroy()
}
Matter.Body.setPosition(this.body, {x: this.body.position.x + increment, y: this.body.position.y})
this.container.y = this.body.position.y
}
}
destroy() {
this.sprite.destroy()
this.container.destroy()
}
}
This class takes over from the initial shot animation of the Hero
class. It makes a sprite based on the shot1_4.png
image travel towards the right, thanks to the update()
method. It also creates a physical Body
and adds it to the physics engine so that we can detect collisions of the shot with enemy fighters. It also defines a destroy()
method that we can call once the shot hits an enemy fighter. When the shots goes beyond the right edge of the screen, it emits a “beyond” message and destroys itself. Let’s use this class in the Hero
class.
/* Other imports */
import { Shoot } from "./Shoot";
export class Hero {
constructor() {
/* Other code */
this.shots = []
}
update(dt) {
/* Other imports */
this.shots.forEach((shot) => shot.update(dt))
}
initShot() {
/* Other code */
this.shot.onComplete = this.completeShot.bind(this)
}
completeShot() {
const location = this.shot.toGlobal(new PIXI.Point(0, 0))
const newShot = new Shoot(location.x, location.y)
this.shots.push(newShot)
newShot.sprite.once('beyond', ()=> {
const shotIndex = this.shots.findIndex(s => s === newShot)
this.shots.splice(shotIndex, 1)
})
this.container.removeChild(this.shot)
App.app.stage.addChild(newShot.container)
}
}
So, we hook up a method completeShot()
to run when the animation finishes playing. We use the Sprite
class’ toGlobal()
method to convert the AnimatedSprite
’s relative coordinates to screen absolute coordinates and pass it on the Shoot
class constructor. We add the new shot object to the shots
array. We hook up an event listener for the beyond
message so that we can remove the shot from our shots
array. We remove the AnimatedSprite
object from the Container
before the Shoot
class moves the shot across the screen. We add the Shoot
class object to the Container
.
Now, if you run the program, you should be able to fire shots that travel across the screen and disappear. Let’s now hook up the logic to destroy the enemy fighters once they are hit by a shot.
/* Imports */
export class GameScene extends Scene {
onCollisionStart(event) {
const colliders = [event.pairs[0].bodyA, event.pairs[0].bodyB]
const hero = colliders.find((body) => body.gameHero)
const fighter = colliders.find((body) => body.gameFighter)
const fighterIndex = colliders.findIndex( (body) => body.gameFighter)
const shot = colliders.find( (body) => body.gameShot)
if (fighter) {
const fighterObj = colliders[fighterIndex].gameFighter
if (hero) {
this.engineSound.stop('engine')
this.explodeHeroAndFighter()
}
else {
sound.play('explosion')
this.hero.destroyShot(shot)
this.explodeFighter(fighterObj)
}
}
}
explodeFighter(fighterObj) {
fighterObj.explode()
}
explodeHeroAndFighter(fighter) {
fighter.explode()
this.hero.explode()
this.up.unsubscribe()
this.down.unsubscribe()
this.shoot.unsubscribe()
}
}
We modify the GameScene
class code slightly. We detect if the collision involves a Shoot
object and a Fighter
object. If that is the case, we play an explosion sound. We call a destroyShot()
method on the Hero
class, and call the explodeFighter()
method. Finally, we need to define the destroyShot()
method in the Hero
class. The purpose of this method is to make the shot disappear since it has destroyed an enemy fighter.
import * as PIXI from "pixi.js";
import { App } from '../system/App';
import * as Matter from 'matter-js'
import {sound} from '@pixi/sound'
import { Shoot } from "./Shoot";
export class Hero {
/* Other code */
destroyShot(shot) {
const index = this.shots.findIndex(s => s.body.id === shot.id)
if (index !== -1) {
this.shots[index].destroy()
this.shots.splice(index, 1)
}
}
}
We use the id
attribute of the Body
from the physics engine to match the shot to be destroyed. We call the destroy()
method of the Shoot
class. We also remove the Shoot
object from the shots
array so that we no longer call updates on it. Now, if you run the game, you should be able to destroy enemy fighters with your weapon.
That’s all folks!
This has been a long blog. I’ll leave you at this point. If you want the complete code, you can get it from this github repository. Let me know if you run into a bug. I’ll continue to work on the score, start screen, and game over. But I’ll let you try that out on your own.