Talking about Mini Archer game, Game development, HTML5, Javascript, Phaser and TypeScript.
Welcome to the the 5th part of the Mini Archer tutorial series. This is quite a big update since I am going to show you how to fire an arrow and make it stick in the target just using trigonometry, with no physics engines.
All posts in this tutorial series:
Step 1: Creation of an endless terrain with infinite randonly generated targets.
Step 2: Adding a running character with more animations.
Step 3: Adding a bow using a Graphics GameObject.
Step 4: Adding the arrow.
Step 5: Firing the arrow.
Step 6: Splitting the code into classes.
If shooting the arrow was rather easy, once we simplified the concept and decided that the arrow will always fly in a straight line, how do we simulate the sticking of the arrow in the target?
Of course with a mask, hiding the part of the arrow Sprite which is on the right side of the target.
Look at the result, built with Phaser 3.60:
Try to shoot the arrow to hit the target.
In previous step I promised to add some custom classes to make the source code more readable, but I preferred to complete the shooting feature first.
But the source code is completely commented and it should be easy for you to see how I built this prototype:
We have one HTML file, one CSS file and four TypeScript files.
index.html
The web page which hosts the game, to be run inside thegame element.
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="initial-scale=1, maximum-scale=1">
<link rel="stylesheet" href="style.css">
<script src="main.js"></script>
</head>
<body>
<div id = "thegame"></div>
</body>
</html>
style.css
The cascading style sheets of the main web page.
* {
padding : 0;
margin : 0;
}
body {
background-color: #011025;
}
canvas {
touch-action : none;
-ms-touch-action : none;
}
gameOptions.ts
Configurable game options. It’s a good practice to place all configurable game options, if possible, in a single and separate file, for a quick tuning of the game. I also grouped the variables to keep them more organized.
// CONFIGURABLE GAME OPTIONS
// changing these values will affect gameplay
export const GameOptions = {
// terrain start, in screen height ratio, where 0 = top, 1 = bottom
terrainStart : 0.6,
// girl x position, in screen width ratio, where 0 = left, 1 = right
girlPosition : 0.15,
target : {
// target position range, in screen width ratio, where 0 = left, 1 = right
positionRange : {
from : 0.6,
to : 0.9
},
// target height range, in pixels
heightRange : {
from : 200,
to : 450
}
},
targetRings : {
// number of rings
amount : 5,
// ring ratio, to make target look oval, this is the ratio of width compared to height
ratio : 0.8,
// ring colors, from external to internal
color : [0xffffff, 0x5cb6f8, 0xe34d46, 0xf2aa3c, 0x95a53c],
// ring radii, from external to internal, in pixels
radius : [50, 40, 40, 30, 20],
// tolerance of ring radius, can be up to this ratio bigger or smaller
radiusTolerance : 0.5
},
rainbow : {
// rainbow rings width, in pixels
width : 5,
// rainbow colors
colors : [0xe8512e, 0xfbb904, 0xffef02, 0x65b33b, 0x00aae5, 0x3c4395, 0x6c4795]
},
arrow : {
// arrow rotation speed, in degrees per second
rotationSpeed : 180,
// flying speed
flyingSpeed: 1000
}
}
main.ts
This is where the game is created, with all Phaser related options.
// MAIN GAME FILE
// modules to import
import Phaser from 'phaser';
import { PreloadAssets } from './preloadAssets';
import { PlayGame } from './playGame';
// object to initialize the Scale Manager
const scaleObject : Phaser.Types.Core.ScaleConfig = {
mode : Phaser.Scale.FIT,
autoCenter : Phaser.Scale.CENTER_BOTH,
parent : 'thegame',
width : 540,
height : 960
}
// game configuration object
const configObject : Phaser.Types.Core.GameConfig = {
type : Phaser.AUTO,
backgroundColor : 0x5df4f0,
scale : scaleObject,
scene : [PreloadAssets, PlayGame]
}
// the game itself
new Phaser.Game(configObject);
preloadAssets.ts
Here we preload all assets to be used in the game.
// CLASS TO PRELOAD ASSETS
// this class extends Scene class
export class PreloadAssets extends Phaser.Scene {
// constructor
constructor() {
super({
key : 'PreloadAssets'
});
}
// method to be execute during class preloading
preload() : void {
// this is how we preload a bitmap font
this.load.image('circle', 'assets/sprites/circle.png');
this.load.image('grasstile', 'assets/sprites/grasstile.png');
this.load.image('dirttile', 'assets/sprites/dirttile.png');
this.load.image('pole', 'assets/sprites/pole.png');
this.load.image('poletop', 'assets/sprites/poletop.png');
this.load.image('cloud', 'assets/sprites/cloud.png');
this.load.image('arrow', 'assets/sprites/arrow.png');
this.load.image('mask', 'assets/sprites/mask.png');
this.load.spritesheet('idlegirl', 'assets/sprites/idlegirl.png', {
frameWidth : 119,
frameHeight : 130
});
this.load.spritesheet('runninggirl', 'assets/sprites/runninggirl.png', {
frameWidth : 119,
frameHeight : 130
});
}
// method to be called once the instance has been created
create() : void {
// call PlayGame class
this.scene.start('PlayGame');
}
}
playGame.ts
Main game file, all game logic is stored here.
// THE GAME ITSELF
import { GameOptions } from './gameOptions';
// game states
enum GameState {
Idle,
Aiming,
Firing
}
// this class extends Scene class
export class PlayGame extends Phaser.Scene {
constructor() {
super({
key : 'PlayGame'
});
}
// current game state
gameState : GameState;
// terrain
terrain : Phaser.GameObjects.TileSprite;
// dirt below the terrain
dirt : Phaser.GameObjects.TileSprite;
// pole
pole : Phaser.GameObjects.TileSprite;
// topmost part of the pole
poleTop : Phaser.GameObjects.Sprite;
// pole shadow
poleShadow : Phaser.GameObjects.Sprite;
// target shadow
targetShadow : Phaser.GameObjects.Sprite;
// target rigns
targetRings : Phaser.GameObjects.Sprite[];
// girl
girl : Phaser.GameObjects.Sprite;
// rainbow
rainbow : Phaser.GameObjects.Graphics;
// clouds
clouds : Phaser.GameObjects.Sprite[];
// arrow
arrow : Phaser.GameObjects.Sprite;
// array with all rings radii
ringRadii : number[];
// mask to simulate arrow sticking in the target
mask : Phaser.GameObjects.Sprite;
// method to be executed when the scene has been created
create() : void {
// at the beginning, the game is in idle state
this.gameState = GameState.Idle;
// define idle animation
this.anims.create({
key : 'idle',
frames: this.anims.generateFrameNumbers('idlegirl', {
start: 0,
end: 15
}),
frameRate: 15,
repeat: -1
});
// define running animation
this.anims.create({
key : 'run',
frames: this.anims.generateFrameNumbers('runninggirl', {
start: 0,
end: 19
}),
frameRate: 15,
repeat: -1
});
// add terrain
let terrainStartY : number = this.game.config.height as number * GameOptions.terrainStart;
this.terrain = this.add.tileSprite(0, terrainStartY, this.game.config.width as number + 256, 256, 'grasstile');
this.terrain.setOrigin(0, 0);
// add dirt, the graphics below the terrain
let dirtStartY : number = terrainStartY + 256;
this.dirt = this.add.tileSprite(0, dirtStartY, this.terrain.width, this.game.config.height as number - dirtStartY, 'dirttile');
this.dirt.setOrigin(0, 0);
// add a circle which represents target shadow
this.targetShadow = this.add.sprite(0, 0, 'circle');
this.targetShadow.setTint(0x676767);
// add pole shadow
let poleYPos : number = terrainStartY + 38;
this.poleShadow = this.add.sprite(0, poleYPos, 'circle');
this.poleShadow.setTint(0x000000);
this.poleShadow.setAlpha(0.2);
this.poleShadow.setDisplaySize(90, 20);
// add pole
this.pole = this.add.tileSprite(0, poleYPos, 32, 0, 'pole');
this.pole.setOrigin(0.5, 1);
// add pole top
this.poleTop = this.add.sprite(0, 0, 'poletop');
this.poleTop.setOrigin(0.5, 1);
// add circles which represent the various target circles
this.targetRings = [];
for (let i : number = 0; i < GameOptions.targetRings.amount; i ++) {
this.targetRings[i] = this.add.sprite(0, 0, 'circle');
}
// girl start position
let girlXPos : number = this.game.config.width as number * GameOptions.girlPosition;
// add girl shadow
let girlShadow : Phaser.GameObjects.Sprite = this.add.sprite(girlXPos + 5, poleYPos, 'circle');
girlShadow.setTint(0x000000);
girlShadow.setAlpha(0.2);
girlShadow.setDisplaySize(60, 20);
// add rainbow
this.rainbow = this.add.graphics();
// add the arrow
this.arrow = this.add.sprite(this.game.config.width as number * 2, 0, 'arrow');
this.arrow.setOrigin(0, 0.5);
// add girl
this.girl = this.add.sprite(girlXPos, poleYPos, 'girl');
this.girl.setOrigin(0.5, 1);
this.girl.anims.play('idle');
// add clouds
this.clouds = [
this.add.sprite(0, 0, 'cloud'),
this.add.sprite(0, 0, 'cloud')
];
// set a custom property to top cloud
this.clouds[1].setData('posY', this.girl.getBounds().top - 50)
// add a tween to move a bit clouds up and down
this.tweens.addCounter({
from : 0,
to : 1,
duration : 1000,
callbackScope : this,
onUpdate : (tween : Phaser.Tweens.Tween) => {
this.clouds[1].y = this.clouds[1].getData('posY') + 5 * Math.cos(Math.PI * tween.getValue())
this.clouds[0].y = this.clouds[0].getData('posY') + 5 * Math.cos(Math.PI * tween.getValue())
},
yoyo : true,
repeat : -1
})
// place a random target at current position
this.placeTarget(this.game.config.width as number * 2, this.pole.y);
// create a mask to simulate arrow sticking in the target
this.mask = this.add.sprite(0, 0, 'mask');
this.mask.setOrigin(0, 0.5)
this.mask.setVisible(false)
let bitmapMask : Phaser.Display.Masks.BitmapMask = this.mask.createBitmapMask();
bitmapMask.invertAlpha = true;
this.arrow.setMask(bitmapMask);
// tween the target to a random position
this.tweenTarget(this.getRandomPosition());
// waiting for player input
this.input.on('pointerdown', this.handlePointer, this);
}
// method to handle pointer
handlePointer() : void {
// is the girl aiming?
if (this.gameState == GameState.Aiming) {
// now the girl is firing
this.gameState = GameState.Firing;
// get radii sum, which is the height of the target
let radiiSum : number = this.ringRadii.reduce((sum, value) => sum + value, 0);
// we define a target line going from the center of the rainbow to the top edge of the target
let topTargetLine : Phaser.Geom.Line = new Phaser.Geom.Line(this.girl.x, this.girl.getBounds().centerY, this.targetRings[0].x, this.targetRings[0].y - radiiSum / 2)
// we define a target line going from the center of the rainbow to the bottom edge of the target
let bottomTargetLine : Phaser.Geom.Line = new Phaser.Geom.Line(this.girl.x, this.girl.getBounds().centerY, this.targetRings[0].x, this.targetRings[0].y + radiiSum / 2);
// get the angle of the top target line and normalize it
let topAngle : number = Phaser.Geom.Line.Angle(topTargetLine);
let topNormalizedAngle : number = Phaser.Math.Angle.Normalize(topAngle);
// get the angle of the bottom target line and normalize it
let bottomAngle : number = Phaser.Geom.Line.Angle(bottomTargetLine);
let bottomNormalizedAngle : number = Phaser.Math.Angle.Normalize(bottomAngle);
// get the normalized arrow angle
let arrowNormalizedAngle : number = Phaser.Math.Angle.Normalize(this.arrow.rotation);
// distance the arrow will travel
let distance : number = this.game.config.width as number * 2;
// if arrow angle is between top and bottom target angle, then arrow hits the target
if (arrowNormalizedAngle >= topNormalizedAngle && arrowNormalizedAngle <= bottomNormalizedAngle) {
// adjusting the distance to make the arrow stop in the horizontal center of the target
distance = (this.targetRings[0].x - this.girl.x) / Math.cos(this.arrow.rotation) - this.arrow.getData('radius') - this.arrow.displayWidth * 0.7;
// place the mask behind target horizontal center and make it big enough
this.mask.x = this.targetRings[0].x;
this.mask.y = this.targetRings[0].y;
this.mask.setDisplaySize(radiiSum, radiiSum * 2)
}
// add the tween to shoot the arrow
this.tweens.add({
// target: the arrow
targets: this.arrow,
// arrow destination, determine with trigonometry
x : this.arrow.x + distance * Math.cos(this.arrow.rotation),
y : this.arrow.y + distance * Math.sin(this.arrow.rotation),
// tween duration, according to distance
duration : distance / GameOptions.arrow.flyingSpeed * 1000,
// tween callback scope
callbackScope : this,
// function to execute when the tween is complete
onComplete : () => {
// add a timer event to wait one second
this.time.addEvent({
// amount of milliseconds to wait
delay : 1000,
// timer callback scope
callbackScope : this,
// function to execute when the timer is complete
callback : () => {
// generic tween of a value from 0 to 1, to make rainbow disappear
this.tweens.addCounter({
from : 0,
to : 1,
// tween duration according to deltaX
duration : 200,
// tween callback scope
callbackScope : this,
// function to be called at each tween update
onUpdate : (tween : Phaser.Tweens.Tween) => {
// get current angle according to rainbow length and tween value
let angle : number = this.rainbow.getData('rainbowlength') - this.rainbow.getData('rainbowlength') * tween.getValue();
// clear rainbow graphics
this.rainbow.clear();
// loop through all rainbow colors
GameOptions.rainbow.colors.forEach((item : number, index : number) => {
// set line style
this.rainbow.lineStyle(GameOptions.rainbow.width, item, 1);
// draw the arc
this.rainbow.beginPath();
this.rainbow.arc(this.girl.x, this.girl.getBounds().centerY, this.rainbow.getData('radiuslength') + index * GameOptions.rainbow.width, this.rainbow.getData('angle'), this.rainbow.getData('angle') + angle, false);
this.rainbow.strokePath();
});
// set posY property of lower cloud
this.clouds[0].setData('posY', this.girl.getBounds().centerY + this.rainbow.getData('radiuslength') * Math.sin(this.rainbow.getData('angle') + angle) + GameOptions.rainbow.colors.length / 2 * GameOptions.rainbow.width)
// set x position of lower cloud
this.clouds[0].setX(this.girl.x + this.rainbow.getData('radiuslength') * Math.cos(this.rainbow.getData('angle') + angle)) ;
},
// function to be called when the tween ends
onComplete : () => {
// hide the cloud
this.clouds[0].setVisible(false)
}
})
// tween the new target
this.tweenTarget(this.getRandomPosition());
}
})
}
})
}
}
// simple metod to get a random target position
getRandomPosition() : number {
return Math.round(Phaser.Math.FloatBetween(GameOptions.target.positionRange.from, GameOptions.target.positionRange.to) * (this.game.config.width as number));
}
// method to draw the rainbow
drawRainbow() : void {
// make a line representing rainbow radius
let rainbowRadius : Phaser.Geom.Line = new Phaser.Geom.Line(this.girl.x, this.girl.getBounds().centerY, this.clouds[1].x, this.clouds[1].getData('posY'));
// get radius length
let rainbowRadiusLength : number = Phaser.Geom.Line.Length(rainbowRadius) - GameOptions.rainbow.colors.length / 2 * GameOptions.rainbow.width;
// get radius angle, which is random start angle
let rainbowStartAngle : number = Phaser.Geom.Line.Angle(rainbowRadius);
this.rainbow.setData('angle', rainbowStartAngle);
// get a random rainbow arc length
let rainbowLength : number = Math.PI / 4 * 3 + Phaser.Math.FloatBetween(0, Math.PI / 4);
this.rainbow.setData('radiuslength', rainbowRadiusLength)
this.rainbow.setData('rainbowlength', rainbowLength);
// hide the lower cloud
this.clouds[0].setVisible(true);
// generic tween of a value from 0 to 1, to make rainbow appear
this.tweens.addCounter({
from : 0,
to : 1,
// tween duration according to deltaX
duration : 200,
// tween callback scope
callbackScope : this,
// method to be called at each tween update
onUpdate : (tween : Phaser.Tweens.Tween) => {
// get current angle according to rainbow length and tween value
let angle : number = rainbowLength * tween.getValue();
// clear rainbow graphics
this.rainbow.clear();
// loop through all rainbow colors
GameOptions.rainbow.colors.forEach((item : number, index : number) => {
// set line style
this.rainbow.lineStyle(GameOptions.rainbow.width, item, 1);
// draw the arc
this.rainbow.beginPath();
this.rainbow.arc(this.girl.x, this.girl.getBounds().centerY, rainbowRadiusLength + index * GameOptions.rainbow.width, rainbowStartAngle, rainbowStartAngle + angle, false);
this.rainbow.strokePath();
});
// set posY property of lower cloud
this.clouds[0].setData('posY', this.girl.getBounds().centerY + rainbowRadiusLength * Math.sin(rainbowStartAngle + angle) + GameOptions.rainbow.colors.length / 2 * GameOptions.rainbow.width);
// set x position of lower cloud
this.clouds[0].setX(this.girl.x + rainbowRadiusLength * Math.cos(rainbowStartAngle + angle)) ;
},
// method to be called when the tween completes
onComplete : () => {
// the girl is aiming
this.gameState = GameState.Aiming;
// place the arrow according to rainbow radius
this.arrow.setPosition(this.girl.x + rainbowRadiusLength - 30, this.girl.getBounds().centerY);
// set some arrow data
this.arrow.setData('radius', rainbowRadiusLength - 30);
this.arrow.setData('start', Phaser.Math.RadToDeg(rainbowStartAngle));
this.arrow.setData('end', Phaser.Math.RadToDeg(rainbowStartAngle + rainbowLength));
this.arrow.setData('mult', 1);
// rotate the arrow according to rainbow radius
this.arrow.setAngle(Phaser.Math.RadToDeg(rainbowStartAngle));
}
})
}
// method to place the target at (posX, posY)
placeTarget(posX : number, posY : number) : void {
// array where to store radii values
this.ringRadii = [];
// determine radii values according to default radius size and tolerance
for (let i : number = 0; i < GameOptions.targetRings.amount; i ++) {
this.ringRadii[i] = Math.round(GameOptions.targetRings.radius[i] + (GameOptions.targetRings.radius[i] * Phaser.Math.FloatBetween(0, GameOptions.targetRings.radiusTolerance) * Phaser.Math.RND.sign()));
}
// get the sum of all radii, this will be the size of the target
let radiiSum : number = this.ringRadii.reduce((sum, value) => sum + value, 0);
// determine target height
let targetHeight : number = posY - Phaser.Math.Between(GameOptions.target.heightRange.from, GameOptions.target.heightRange.to)
// set pole shadow x poisition
this.poleShadow.setX(posX);
// set pole x position
this.pole.setX(posX);
// set pole height
this.pole.height = posY - targetHeight;
// set pole top position
this.poleTop.setPosition(posX, this.pole.y - this.pole.displayHeight - radiiSum / 2 + 10);
// set shadow size
this.targetShadow.setDisplaySize(radiiSum * GameOptions.targetRings.ratio, radiiSum);
// set target shadow position
this.targetShadow.setPosition(posX + 5, targetHeight);
// loop through all rings
for (let i : number = 0; i < GameOptions.targetRings.amount; i ++) {
// set ring position
this.targetRings[i].setPosition(posX, targetHeight);
// set ring tint
this.targetRings[i].setTint(GameOptions.targetRings.color[i]);
// set ring diplay size
this.targetRings[i].setDisplaySize(radiiSum * GameOptions.targetRings.ratio, radiiSum);
// decrease radiiSum to get the radius of next ring
radiiSum -= this.ringRadii[i];
}
}
// method to tween the target to posX
tweenTarget(posX : number) : void {
// array with all target related stuff to move
let stuffToMove : any[] = [this.pole, this.poleTop, this.poleShadow, this.targetShadow, this.terrain, this.dirt, this.mask, this.arrow].concat(this.targetRings);
// delta X between current target position and destination position
let deltaX : number = this.game.config.width as number * 2 - posX;
// variable to save previous value
let previousValue : number = 0;
// variable to save the amount of pixels already travelled
let totalTravelled : number = 0;
// next cloud X position
let nextCloudX : number = this.girl.x - 50 + Phaser.Math.Between(0, 100);
// next cloud y position
let nextCloudY : number = this.girl.getBounds().top - Phaser.Math.Between(50, 100);
// object which will follow a path
let follower : any = {
t: 0,
vec: new Phaser.Math.Vector2()
};
// define cloud movement line
let movementLine : Phaser.Curves.Line = new Phaser.Curves.Line([this.clouds[1].x, this.clouds[1].getData('posY'), nextCloudX, nextCloudY]);
// add a path
var path : Phaser.Curves.Path = this.add.path(0, 0);
// add movement line to path
path.add(movementLine);
// move the cloud along the path
this.tweens.add({
targets: follower,
t: 1,
ease: 'Linear',
duration : deltaX * 3,
callbackScope : this,
onUpdate : () => {
var point = path.getPoint(follower.t, follower.vec)
this.clouds[1].setX(point.x)
this.clouds[1].setData('posY', point.y);
}
});
// play girl's "run" animation
this.girl.anims.play('run');
// tween a number from 0 to 1
this.tweens.addCounter({
from : 0,
to : 1,
// tween duration according to deltaX
duration : deltaX * 3,
// tween callback scope
callbackScope : this,
// method to be called at each tween update
onUpdate : (tween : Phaser.Tweens.Tween) => {
// delta between previous and current value
let delta : number = tween.getValue() - previousValue;
// update previous value to current value
previousValue = tween.getValue();
// determine the amount of pixels travelled
totalTravelled += delta * deltaX;
// move all stuff
stuffToMove.forEach((item : any) => {
item.x -= delta * deltaX;
})
// adjust the seamless terrain when it goes too much outside the screen
if (this.terrain.x < -256) {
this.terrain.x += 256;
}
// adjust the seamless dirt when it goes too much outside the screen
if (this.dirt.x < -256) {
this.dirt.x += 256;
}
// if the target left the canvas from the left side...
if (this.targetShadow.getBounds().right < 0) {
// reposition it on the right side
this.placeTarget(this.game.config.width as number * 2 - totalTravelled, this.pole.y);
}
},
// method to be called when the tween completes
onComplete : () => {
// play girl's "idle" animation
this.girl.anims.play('idle');
// draw the rainbow
this.drawRainbow();
}
})
}
// method to be executed at each frame
update(time : number, deltatime : number) : void {
// is the arrow visible?
if (this.arrow.visible == true && this.gameState != GameState.Firing) {
// rotate the arrow according to arrow speed
this.arrow.angle += GameOptions.arrow.rotationSpeed * deltatime / 1000 * this.arrow.getData('mult');
// did the arrow reach the end of the rainbow?
if (this.arrow.angle > this.arrow.getData('end')) {
// don't let it go further
this.arrow.angle = this.arrow.getData('end');
// invert arrow rotation direction
this.arrow.setData('mult', this.arrow.getData('mult') * -1);
}
// did the arrow reach the beginning of the rainbow?
if (this.arrow.angle < this.arrow.getData('start')) {
// don't let the arrow go further
this.arrow.angle = this.arrow.getData('start');
// invert arrow rotation direction
this.arrow.setData('mult', this.arrow.getData('mult') * -1);
}
// set arrow position according to its rotation
this.arrow.setPosition(this.girl.x + this.arrow.getData('radius') * Math.cos(this.arrow.rotation), this.girl.getBounds().centerY + this.arrow.getData('radius') * Math.sin(this.arrow.rotation))
}
}
}
And finally the running girl is able to shoot arrows to an infinite sequence of randomly generated targets running along an endless terrain. Download the source code of the entire project.
Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.