Talking about Mini Archer game, Game development, HTML5, Javascript, Phaser and TypeScript.
In this fourth part of the tutorial series I am adding the arrow.
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.
Arrow appears once the rainbow has been created, and keeps moving along rainbow until the rainbow is removed. Look at the result:
Now the rainbow disappears after a certain amount of time, but later I will wait for player input to shoot the arrow.
This is the last version of this prototype to be built in a single class, from next step on I will be using custom classes for arrow, target and all other assets.
This is the commented source code: 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.
// 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 position range, in screen width ratio, where 0 = left, 1 = right
targetPositionRange : {
from : 0.5,
to : 0.9
},
// target height range, in pixels
targetHeightRange : {
from : 150,
to : 350
},
// number of rings
rings : 5,
// ring ratio, to make target look oval, this is the ratio of width compared to height
ringRatio : 0.8,
// ring colors, from external to internal
ringColor : [0xffffff, 0x5cb6f8, 0xe34d46, 0xf2aa3c, 0x95a53c],
// ring radii, from external to internal, in pixels
ringRadius : [45, 35, 35, 25, 15],
// tolerance of ring radius, can be up to this ratio bigger or smaller
ringRadiusTolerance : 0.5,
// rainbow colors
rainbowColors : [0xe8512e, 0xfbb904, 0xffef02, 0x65b33b, 0x00aae5, 0x3c4395, 0x6c4795],
// rainbow rings width, in pixels
rainbowWidth : 5,
// arrow rotation speed, in degrees per second
arrowSpeed : 180
}
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.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';
// this class extends Scene class
export class PlayGame extends Phaser.Scene {
constructor() {
super({
key : 'PlayGame'
});
}
// 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;
// method to be executed when the scene has been created
create() : void {
// 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.rings; 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 girl
this.girl = this.add.sprite(girlXPos, poleYPos, 'girl');
this.girl.setOrigin(0.5, 1);
this.girl.anims.play('idle');
// add rainbow
this.rainbow = this.add.graphics();
// add the arrow
this.arrow = this.add.sprite(0, 0, 'arrow');
this.arrow.setVisible(false);
this.arrow.setOrigin(0, 0.5);
// 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);
// tween the target to a random position
this.tweenTarget(this.getRandomPosition());
}
// simple metod to get a random target position
getRandomPosition() : number {
return Math.round(Phaser.Math.FloatBetween(GameOptions.targetPositionRange.from, GameOptions.targetPositionRange.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.rainbowColors.length / 2 * GameOptions.rainbowWidth;
// get radius angle, which is random start angle
let rainbowStartAngle : number = Phaser.Geom.Line.Angle(rainbowRadius);
// get a random rainbow arc length
let rainbowLength : number = Math.PI / 4 * 3 + Phaser.Math.FloatBetween(0, Math.PI / 4);
// 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.rainbowColors.forEach((item : number, index : number) => {
// set line style
this.rainbow.lineStyle(GameOptions.rainbowWidth, item, 1);
// draw the arc
this.rainbow.beginPath();
this.rainbow.arc(this.girl.x, this.girl.getBounds().centerY, rainbowRadiusLength + index * GameOptions.rainbowWidth, 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.rainbowColors.length / 2 * GameOptions.rainbowWidth);
// 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 : () => {
// show the arrow
this.arrow.setVisible(true);
// 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));
// add a time event
this.time.addEvent({
// wait 5 seconds
delay : 5000,
// tween callback scope
callbackScope : this,
// callback function
callback : () => {
this.arrow.setVisible(false);
// 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 - rainbowLength * tween.getValue();
// clear rainbow graphics
this.rainbow.clear();
// loop through all rainbow colors
GameOptions.rainbowColors.forEach((item : number, index : number) => {
// set line style
this.rainbow.lineStyle(GameOptions.rainbowWidth, item, 1);
// draw the arc
this.rainbow.beginPath();
this.rainbow.arc(this.girl.x, this.girl.getBounds().centerY, rainbowRadiusLength + index * GameOptions.rainbowWidth, 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.rainbowColors.length / 2 * GameOptions.rainbowWidth)
// set x position of lower cloud
this.clouds[0].setX(this.girl.x + rainbowRadiusLength * Math.cos(rainbowStartAngle + angle)) ;
},
onComplete : () => {
this.clouds[0].setVisible(false)
}
})
// tween the new target
this.tweenTarget(this.getRandomPosition());
}
});
}
})
}
// method to place the target at (posX, posY)
placeTarget(posX : number, posY : number) : void {
// array where to store radii values
let ringRadii : number[] = [];
// determine radii values according to default radius size and tolerance
for (let i : number = 0; i < GameOptions.rings; i ++) {
ringRadii[i] = Math.round(GameOptions.ringRadius[i] + (GameOptions.ringRadius[i] * Phaser.Math.FloatBetween(0, GameOptions.ringRadiusTolerance) * Phaser.Math.RND.sign()));
}
// get the sum of all radii, this will be the size of the target
let radiiSum : number = ringRadii.reduce((sum, value) => sum + value, 0);
// determine target height
let targetHeight : number = posY - Phaser.Math.Between(GameOptions.targetHeightRange.from, GameOptions.targetHeightRange.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.ringRatio, radiiSum);
// set target shadow position
this.targetShadow.setPosition(posX + 5, targetHeight);
// loop through all rings
for (let i : number = 0; i < GameOptions.rings; i ++) {
// set ring position
this.targetRings[i].setPosition(posX, targetHeight);
// set ring tint
this.targetRings[i].setTint(GameOptions.ringColor[i]);
// set ring diplay size
this.targetRings[i].setDisplaySize(radiiSum * GameOptions.ringRatio, radiiSum);
// decrease radiiSum to get the radius of next ring
radiiSum -= 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].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) {
// rotate the arrow according to arrow speed
this.arrow.angle += GameOptions.arrowSpeed * 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))
}
}
}
The prototype is almost completed, I just need to make the girl fire the arrow, so we are just one step away from a completely playable prototype. 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.