Talking about Game development, HTML5, Javascript and Phaser.
Do you remember my DrawSum HTML5 math game?
It was a commercial game built with an older Phaser CE version, whose source code I released for free, and although the Phaser version was a bit outdated, it’s easy to port it to Phaser 3.
Today I am showing you a pure JavaScript class to handle math draw games like DrawSum.
It does not have any dependency so you can use it with your favourite framework, and you just have to call five methods to handle the entire game engine. This way, you will only need to focus on input management and visual effects.
First things first, let’s have a look at a prototype built in a bunch of lines thanks to this class:
Draw to connect tiles and watch the sum update in real time. Then, look at new tiles falling with new numbers.
This is all managed by a bunch of methods:
let drawSum = new DrawSum({
rows: 5,
columns: 5,
items: 9
});
This constructor builds a new game field with five rows, five columns and nine items.
How to see what we built, and add sprites accordingly?
let board = JSON.parse(drawSum.getBoardStatus());
getBoardStatus
returns a JSON string like this one:
[
{"row":0,"column":0,"value":9,"inChain":false,"prevChain":false,"nextChain":false},
{"row":0,"column":1,"value":5,"inChain":false,"prevChain":false,"nextChain":false},
{"row":0,"column":2,"value":6,"inChain":false,"prevChain":false,"nextChain":false},
{"row":0,"column":3,"value":5,"inChain":false,"prevChain":false,"nextChain":false},
{"row":0,"column":4,"value":2,"inChain":false,"prevChain":false,"nextChain":false},
{"row":1,"column":0,"value":6,"inChain":false,"prevChain":false,"nextChain":false},
{"row":1,"column":1,"value":9,"inChain":false,"prevChain":false,"nextChain":false},
{"row":1,"column":2,"value":7,"inChain":false,"prevChain":false,"nextChain":false},
{"row":1,"column":3,"value":2,"inChain":false,"prevChain":false,"nextChain":false},
{"row":1,"column":4,"value":4,"inChain":false,"prevChain":false,"nextChain":false},
{"row":2,"column":0,"value":6,"inChain":false,"prevChain":false,"nextChain":false},
{"row":2,"column":1,"value":9,"inChain":false,"prevChain":false,"nextChain":false},
{"row":2,"column":2,"value":2,"inChain":false,"prevChain":false,"nextChain":false},
{"row":2,"column":3,"value":3,"inChain":false,"prevChain":false,"nextChain":false},
{"row":2,"column":4,"value":7,"inChain":false,"prevChain":false,"nextChain":false},
{"row":3,"column":0,"value":6,"inChain":false,"prevChain":false,"nextChain":false},
{"row":3,"column":1,"value":6,"inChain":false,"prevChain":false,"nextChain":false},
{"row":3,"column":2,"value":7,"inChain":false,"prevChain":false,"nextChain":false},
{"row":3,"column":3,"value":6,"inChain":false,"prevChain":false,"nextChain":false},
{"row":3,"column":4,"value":2,"inChain":false,"prevChain":false,"nextChain":false},
{"row":4,"column":0,"value":7,"inChain":false,"prevChain":false,"nextChain":false},
{"row":4,"column":1,"value":8,"inChain":false,"prevChain":false,"nextChain":false},
{"row":4,"column":2,"value":1,"inChain":false,"prevChain":false,"nextChain":false},
{"row":4,"column":3,"value":5,"inChain":false,"prevChain":false,"nextChain":false},
{"row":4,"column":4,"value":7,"inChain":false,"prevChain":false,"nextChain":false}
]
Let’s have a look at the items:
row: item row number.
column: item column number.
value: item value
inChain: false
if not selected, or a number representing the order in the chain if selected. First item has 0 as inChain value.
prevChain: false
if the item does not have a previous item in the chain, or the coordinates of the previous item if it exists.
nextChain: false
if the item does not have a previous item in the chain, or the coordinates of the previous item if it exists.
With these data, you are able to draw your game field, connect selected items, and do everything to display the game.
How do you link graphic assets to your game board?
drawSum.setCustomData(row, column, data);
setCustomData
method saves anything you want in the item at (row
, column
) coordinate.
Following the same concept, we can retrieve data when needed:
let data = drawSum.getCustomData(row, column);
getCustomData
method retrieves the data stored with setCustomData
.
How do you pick items?
drawSum.pickItemAt(row, column);
pickItemAt
method handles everything involved in item selection. It checks if the item is in a legal position, if it can be added to the chain of selected items or if we have to discard the latest item in the chain because the player is backtracking.
One method to rule them all, and it just returns true
if this was a legal move or false
, if it wasn’t.
Then, you can update your graphics calling getBoardStatus
one more time.
When you completed the selection, there is one last method to call:
drawSum.handleMove();
handleMove
method returns all movement information, as a JSON string to handle items to be removed, items to make fall down and items to make fall from above to replenish the board, like in this example:
{
"itemsToArrange":[
{"row":2,"column":1,"deltaRow":1},
{"row":2,"column":2,"deltaRow":1},
{"row":3,"column":3,"deltaRow":2},
{"row":1,"column":1,"deltaRow":1},
{"row":1,"column":2,"deltaRow":1},
{"row":2,"column":3,"deltaRow":2}
],
"itemsToReplenish":[
{"row":0,"column":1,"deltaRow":1},
{"row":0,"column":2,"deltaRow":1},
{"row":0,"column":3,"deltaRow":2},
{"row":1,"column":3,"deltaRow":2}
]}
itemsToArrange and itemsToReplenish items contain the row and the column of the item to move, and the amount of rows (deltaRow) to move to.
Look at the complete example:
let game;
let gameOptions = {
cellSize: 140,
fallSpeed: 100,
destroySpeed: 200,
boardOffset: {
x: 25,
y: 25
}
}
window.onload = function() {
let gameConfig = {
type: Phaser.AUTO,
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
parent: "thegame",
width: 750,
height: 1334
},
scene: playGame
}
game = new Phaser.Game(gameConfig);
window.focus();
}
class playGame extends Phaser.Scene {
constructor() {
super("PlayGame");
}
preload() {
this.load.spritesheet("gems", "assets/sprites/gems.png", {
frameWidth: gameOptions.cellSize,
frameHeight: gameOptions.cellSize
});
this.load.spritesheet("arrows", "assets/sprites/arrows.png", {
frameWidth: gameOptions.cellSize * 3,
frameHeight: gameOptions.cellSize * 3
});
}
create() {
this.canPick = true;
this.drawing = false;
// constructor: here we define the number of rows, columns and different items
this.drawSum = new DrawSum({
rows: 5,
columns: 5,
items: 9
});
// getBoardStatus() returns a JSON string with all board information
let board = JSON.parse(this.drawSum.getBoardStatus());
board.forEach(cell => this.addSprite(cell))
this.sumText = this.add.text(game.config.width / 2, 900, "0", {
fontFamily: "Arial",
fontSize: 256,
color: "#2394bc"
});
this.sumText.setOrigin(0.5, 0);
this.input.on("pointerdown", this.startDrawing, this);
this.input.on("pointermove", this.keepDrawing, this);
this.input.on("pointerup", this.stopDrawing, this);
}
// adding sprites
addSprite(cell) {
let posX = gameOptions.boardOffset.x + gameOptions.cellSize * cell.column + gameOptions.cellSize / 2;
let posY = gameOptions.boardOffset.y + gameOptions.cellSize * cell.row + gameOptions.cellSize / 2
let item = this.add.sprite(posX, posY, "gems", cell.value - 1);
let arrow = this.add.sprite(posX, posY, "arrows");
arrow.setDepth(2);
arrow.visible = false;
// setCustomData method allows us to inject custom information in the board,
// in our case an object containing both item and arrow sprites
this.drawSum.setCustomData(cell.row, cell.column, {
itemSprite: item,
arrowSprite: arrow
});
}
// method to call when the player presses the input
startDrawing(pointer) {
if (this.canPick) {
this.handleDrawing(pointer, true)
}
}
// method to call when the player moves the input
keepDrawing(pointer) {
if (this.drawing && this.pointerInside(pointer)) {
this.handleDrawing(pointer, false);
}
}
// method to handle player movement
handleDrawing(pointer, firstPick) {
let row = Math.floor((pointer.y - gameOptions.boardOffset.y) / gameOptions.cellSize);
let col = Math.floor((pointer.x - gameOptions.boardOffset.x) / gameOptions.cellSize);
// pickItemAt method handles player pick
let pickedItem = this.drawSum.pickItemAt(row, col);
if (pickedItem) {
this.displayValue();
this.updateVisuals(false);
if (firstPick) {
this.canPick = false;
this.drawing = true;
}
}
}
// just a method to update visuals
updateVisuals(changeFrame) {
let board = JSON.parse(this.drawSum.getBoardStatus());
board.forEach(cell => this.handleCell(cell, changeFrame));
}
// method to draw and update sprites
handleCell(cell, changeFrame) {
// getCustomData method retrieves the information previously stored with setCustomData
this.drawSum.getCustomData(cell.row, cell.column).itemSprite.alpha = cell.inChain !== false ? 0.5 : 1;
if (changeFrame) {
this.drawSum.getCustomData(cell.row, cell.column).itemSprite.setFrame(cell.value - 1);
}
let arrow = this.drawSum.getCustomData(cell.row, cell.column).arrowSprite;
if (cell.nextChain) {
arrow.visible = true;
let deltaRow = cell.nextChain.row - cell.row;
let deltaColumn = cell.nextChain.column - cell.column;
arrow.setFrame(deltaRow == 0 ? 0 : (deltaColumn == 0 ? 0 : 1));
arrow.angle = deltaRow == -1 ? (deltaColumn == 0 ? -90 : (deltaColumn == 1 ? 0 : -90)) : (deltaRow == 0 ? (deltaColumn == -1 ? 180 : 0) : (deltaColumn == 0 ? 90 : (deltaColumn == 1 ? 90 : 180)))
}
else {
arrow.visible = false;
}
}
// method to call when the input stops
stopDrawing() {
if (this.drawing) {
this.drawing = false;
// handleMove method processes player move and returns a JSON string with all movement information
this.moves = JSON.parse(this.drawSum.handleMove());
this.displayValue();
this.updateVisuals();
this.makeItemsDisappear();
}
}
// method to remove items
makeItemsDisappear() {
this.destroyed = 0;
this.moves.itemsToReplenish.forEach (item => this.fadeItem(item));
}
// method to fade out sprites
fadeItem(item) {
this.destroyed ++;
let data = this.drawSum.getCustomData(item.row, item.column);
this.tweens.add({
targets: data.itemSprite,
alpha: 0,
duration: gameOptions.destroySpeed,
callbackScope: this,
onComplete: function(event, sprite) {
this.destroyed --;
if (this.destroyed == 0) {
this.makeItemsFall();
}
}
});
}
// method to handle items falling
makeItemsFall() {
this.updateVisuals(true);
this.moved = 0;
this.moves.itemsToArrange.forEach(movement => this.fallDown(movement));
this.moves.itemsToReplenish.forEach(movement => this.fallFromTop(movement));
}
// method to make items fall down
fallDown(movement) {
let data = this.drawSum.getCustomData(movement.row, movement.column);
this.tweenItem([data.itemSprite, data.arrowSprite], data.itemSprite.y + movement.deltaRow * gameOptions.cellSize, gameOptions.fallSpeed * Math.abs(movement.deltaRow))
}
// method to make items fall from top
fallFromTop(movement) {
let data = this.drawSum.getCustomData(movement.row, movement.column);
data.itemSprite.alpha = 1;
data.itemSprite.y = gameOptions.boardOffset.y + gameOptions.cellSize * (movement.row - movement.deltaRow + 1) - gameOptions.cellSize / 2;
data.itemSprite.x = gameOptions.boardOffset.x + gameOptions.cellSize * movement.column + gameOptions.cellSize / 2,
this.tweenItem([data.itemSprite, data.arrowSprite], gameOptions.boardOffset.y + gameOptions.cellSize * movement.row + gameOptions.cellSize / 2, gameOptions.fallSpeed * movement.deltaRow);
}
// method to tween an item
tweenItem(item, destinationY, duration) {
this.moved ++;
this.tweens.add({
targets: item,
y: destinationY,
duration: duration,
callbackScope: this,
onComplete: function() {
this.moved --;
if(this.moved == 0){
this.canPick = true;
}
}
});
}
// method to display the sum of the selected items
displayValue() {
let sum = 0;
let board = JSON.parse(this.drawSum.getBoardStatus());
board.forEach (function(cell) {
if (cell.inChain !== false) {
sum += cell.value;
}
});
this.sumText.setText(sum);
}
// method to check if the pointer is inside enough a cell, to allow player to easily move diagonally
pointerInside(pointer) {
let row = Math.floor((pointer.y - gameOptions.boardOffset.y) / gameOptions.cellSize);
let column = Math.floor((pointer.x - gameOptions.boardOffset.x) / gameOptions.cellSize);
let perfectX = column * gameOptions.cellSize + gameOptions.boardOffset.x + gameOptions.cellSize / 2;
let perfectY = row * gameOptions.cellSize + gameOptions.boardOffset.y + gameOptions.cellSize / 2;
let manhattanDistance = Math.abs(pointer.x - perfectX) + Math.abs(pointer.y - perfectY);
return manhattanDistance < gameOptions.cellSize * 0.4;
}
}
// DrawSum class
class DrawSum {
// constructor, sets up and builds the game field
constructor(obj) {
if (obj == undefined) {
obj = {};
}
this.rows = (obj.rows != undefined) ? obj.rows : 8;
this.columns = (obj.columns != undefined) ? obj.columns : 7;
this.items = (obj.items != undefined) ? obj.items : 6;
this.chain = new Chain();
this.gameArray = [];
for (let i = 0; i < this.rows; i ++) {
this.gameArray[i] = [];
for (let j = 0; j < this.columns; j ++) {
let randomValue = Math.ceil(Math.random() * this.items);
this.gameArray[i][j] = new Cell(new Item(randomValue))
}
}
}
// returns a JSON string with board status
getBoardStatus() {
let board = [];
for (let i = 0; i < this.rows; i ++) {
for (let j = 0; j < this.columns; j ++) {
board.push({
row: i,
column: j,
value: this.gameArray[i][j].getItemValue(),
inChain: this.chain.getPosition(i, j),
prevChain: this.chain.getPrevious(i, j),
nextChain: this.chain.getNext(i, j)
});
}
}
return JSON.stringify(board);
}
// tries to pick an item at (row, column). Returns true if the pick was successful, false otherwise
pickItemAt(row, column) {
if (this.isValidPick(row, column)) {
if (this.chain.canContinue(row, column)) {
this.chain.add(row, column, this.gameArray[row][column].getItem());
return true;
}
if (this.chain.isBacktrack(row, column)) {
this.chain.backtrack();
return true;
}
return false;
}
return false;
}
// returns true if the item at (row, column) is a valid pick, false otherwise
isValidPick(row, column) {
return row >= 0 && row < this.rows && column >= 0 && column < this.columns;
}
// sets a custom data of the item at (row, column)
setCustomData(row, column, customData) {
this.gameArray[row][column].setItemData(customData);
}
// returns the custom data of the item at (row, column)
getCustomData(row, column) {
return this.gameArray[row][column].getItemData();
}
// handles the board after a move, and return a JSON object with movement information
handleMove() {
let result = this.chain.export();
this.chain.clear();
result.forEach(function(item){
this.gameArray[item.row][item.column].setEmpty(true);
}.bind(this));
return JSON.stringify({
itemsToArrange: this.arrangeBoard(),
itemsToReplenish: this.replenishBoard()
});
}
// swaps the items at (row, column) and (row2, column2)
swapItems(row, column, row2, column2) {
let tempObject = this.gameArray[row][column];
this.gameArray[row][column] = this.gameArray[row2][column2];
this.gameArray[row2][column2] = tempObject;
}
// returns the amount of empty spaces below the item at (row, column)
emptySpacesBelow(row, column) {
let result = 0;
if (row != this.rows) {
for (let i = row + 1; i < this.rows; i ++) {
if (this.gameArray[i][column].isEmpty()) {
result ++;
}
}
}
return result;
}
// arranges the board after a chain, making items fall down. Returns an object with movement information
arrangeBoard() {
let result = []
for (let i = this.rows - 2; i >= 0; i --) {
for (let j = 0; j < this.columns; j ++) {
let emptySpaces = this.emptySpacesBelow(i, j);
if(!this.gameArray[i][j].isEmpty() && emptySpaces > 0) {
this.swapItems(i, j, i + emptySpaces, j);
result.push({
row: i + emptySpaces,
column: j,
deltaRow: emptySpaces
});
}
}
}
return result;
}
// replenishes the board and returns an object with movement information
replenishBoard() {
let result = [];
for (let i = 0; i < this.columns; i ++) {
if (this.gameArray[0][i].isEmpty()) {
let emptySpaces = this.emptySpacesBelow(0, i) + 1;
for (let j = 0; j < emptySpaces; j ++) {
let randomValue = Math.ceil(Math.random() * this.items);
result.push({
row: j,
column: i,
deltaRow: emptySpaces
});
this.gameArray[j][i].setItemValue(randomValue);
this.gameArray[j][i].setEmpty(false);
}
}
}
return result;
}
}
// Chain class
class Chain {
// constructor, sets the chain as an empty array
constructor() {
this.chain = [];
}
// adds an item
add(row, column, item) {
this.chain.push({
item: item,
row: row,
column: column
});
}
// clears the chain
clear() {
this.chain = [];
this.chain.length = 0;
}
// backtracks the chain and returns the removed item
backtrack() {
return this.chain.pop();
}
// returns the position of the item at (row, column) in chain, or false otherwise
getPosition(row, column) {
for(let i = 0; i < this.chain.length; i ++){
if (this.chain[i].row == row && this.chain[i].column == column) {
return i;
}
}
return false;
}
// returns the coordinates of previous item relative to item at (row, column)
getPrevious(row, column) {
let position = this.getPosition(row, column);
if (position > 0) {
return {
row: this.chain[position - 1].row,
column: this.chain[position - 1].column
}
}
return false;
}
// returns the coordinates of the next item relative to item at (row, column)
getNext(row, column) {
let position = this.getPosition(row, column);
if (position !== false && position < this.chain.length - 1) {
return {
row: this.chain[position + 1].row,
column: this.chain[position + 1].column
}
}
return false;
}
// returns true if item at (row, column) can be next chain item
canBeNext(row, column) {
if (this.chain.length == 0) {
return true;
}
let lastItem = this.chain[this.chain.length - 1];
return (Math.abs(row - lastItem.row) + Math.abs(column - lastItem.column) == 1) || (Math.abs(row - lastItem.row) == 1 && Math.abs(column - lastItem.column) == 1);
}
// return true if the element at (row, column) is the element to backtrack
isBacktrack(row, column) {
return this.getPosition(row, column) == this.chain.length - 2;
}
// returns true if the item at (row, column) continues the chain
canContinue(row, column) {
return this.getPosition(row, column) === false && this.canBeNext(row, column);
}
// exports the chain
export() {
let path = [];
for (let i = 0; i < this.chain.length; i ++) {
path.push({
row: this.chain[i].row,
column: this.chain[i].column,
});
}
return path;
}
}
// Cell class
class Cell {
// constructor, has an item and an "empty" property
constructor(item) {
this.item = item;
this.empty = false;
}
// returns true if the cell is empty
isEmpty() {
return this.empty;
}
// sets empty status
setEmpty(empty) {
this.empty = empty;
}
// returns the item
getItem() {
return this.item;
}
// returns item value
getItemValue() {
return this.item.getValue();
}
// sets item value
setItemValue(value) {
this.item.setValue(value);
}
// returns item data
getItemData() {
return this.item.getData();
}
// sets item data
setItemData(data) {
return this.item.setData(data);
}
}
// Item class
class Item {
// constructor, has a value and a custom data
constructor(value, data) {
this.value = value;
this.customData = data;
}
// returns custom data
getData() {
return this.customData;
}
// sets custom data
setData(data) {
this.customData = data;
}
// returns value
getValue() {
return this.value;
}
// sets value
setValue(value) {
this.value = value;
}
}
Try to play with this example and you will find really easy to code your own draw and sum game. I am rebuilding the original DrawSum game with it.
Download the source code of the entire project, and build your draw and sum game.
Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.