Talking about Drag and Match game, Game development, HTML5, Javascript, Phaser and TypeScript.
If you are going to build a Drag and Match game, you need to be able to detect matches.
In previous step, I showed you a pure TypeScript class to handle Drag and Match games allowing you to build a simple Drag and Match prototype in a few lines. There’s also a jQuery example available.
Look at this example powered by Phaser:
Drag rows and columns and try to do some matches, they will be listed below the game field, saying if it is a vertical or horizontal match, start and end coordinates (remember first row and first column are respectively row 0 and column 0), match length and item id.
This is everything you need to assign score, bonus, combos and so on.
Everything is possible by reading a single property called matches.
This means now you can manage a Drag and Match game with just a few methods, let me show you the recap:
Everything is managed by the class, whose constructor allows you to create a game with a lot of options:
rows: amount of rows.
columns: amount of columns.
items: amount of different items.
match: amount of equal consecutive items to define a match. Normally we define a match when we have 3 or more consecutive items, but it’s up to you.
tileSize: tile size, in pixels. Useful to let the class calculate items movements and positions for you.
startX: horizontal coordinate of the top left item, in pixels.
startY: vertical coordinate of the top left item, in pixels.
minDragDistance: minimum input movement to say the player is trying to drag a row or a column.
You don’t have to set all values, in this case the class will use default values but obviously it’s recommended to set all values.
Once you created your Drag and Match instance, with items property you get an array of objects with item positions, ready to be placed to the canvas.
Each item has a data property you can use to store your custom information, such as a Phaser sprite or anything else you may want to bind to such item.
isInputInsideBoard method, given an input coordinate, returns true if the input is inside the board, false otherwise.
handleInputMovement method, given start input coordinate an current input coordinate, calculates item movements and returns all information you need to place both the actual items and the dummy item used to fake the wraparound effect.
handleInputStop method, given start input coordinate an current input coordinate, calculate item final destination and updated the game board, returning all item information.
Finally matches property gives you all matches information.
Let’s see the source code, which is made of one html file and 4 TypeScript files:
index.html
The web page which hosts the game, to be run inside thegame element.
<!DOCTYPE html>
<html>
<head>
<style type = "text/css">
* {
padding: 0;
margin: 0;
}
body{
background: #000;
}
canvas {
touch-action: none;
-ms-touch-action: none;
}
</style>
<script src = "main.js"></script>
</head>
<body>
<div id = "thegame"></div>
</body>
</html>
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 : 1000,
height : 1200
}
// game configuration object
const configObject : Phaser.Types.Core.GameConfig = {
type : Phaser.AUTO,
backgroundColor : 0x222222,
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.load.spritesheet('items', 'assets/items.png', {
frameWidth : 100,
frameHeight : 100
});
}
// method to be called once the instance has been created
create(): void {
// call PlayGame class
this.scene.start('PlayGame');
}
}
playGame.ts
Main game file, where we handle input and visual effects. I highlighted the lines of code that use my class.
// THE GAME ITSELF
// modules to import
import { DragAndMatch } from './dragAndMatch';
// this class extends Scene class
export class PlayGame extends Phaser.Scene {
// drag and match instance
dragAndMatch : DragAndMatch;
// can the player drag?
canDrag : boolean;
// text to display matches
infoText : Phaser.GameObjects.Text;
// graphics object to highlight dummy item
dummyHighlight : Phaser.GameObjects.Graphics;
// constructor
constructor() {
super({
key: 'PlayGame'
});
}
create() : void {
this.infoText = this.add.text(25, 725, '', {
fontSize : '36px'
})
// player can't drag
this.canDrag = false;
// setting up a drag and match game with only some options
this.dragAndMatch = new DragAndMatch({
startX : 100,
startY : 100
});
// get all game items
this.dragAndMatch.items.map((item) => {
// create a sprite and place it to posX, posY, with a "value" frame
let sprite : Phaser.GameObjects.Sprite = this.add.sprite(item.posX, item.posY, 'items', item.value);
// set sprite registration point
sprite.setOrigin(0);
// is this the dummy item?
if (item.isDummy) {
// hide dummy item
sprite.setVisible(false);
}
// save sprite information into data custom property
item.data = sprite;
});
// input listeners
this.input.on('pointerdown', this.inputStart, this);
this.input.on('pointermove', this.inputMove, this);
this.input.on('pointerup', this.inputStop, this);
// just some graphics to show game area and highlight dummy item
// values are hardcoded so if you change game configuration, you need to change these values too.
// Anyway, this does not affect the game itself
let graphics : Phaser.GameObjects.Graphics = this.add.graphics();
graphics.lineStyle(4, 0xff0000, 1);
graphics.strokeRect(100, 100, 800, 600);
this.dummyHighlight = this.add.graphics();
this.dummyHighlight.lineStyle(4, 0x00ff00, 1);
this.dummyHighlight.strokeRect(0, 0, 100, 100);
this.dummyHighlight.setVisible(false);
}
// input start callback
inputStart(pointer : Phaser.Input.Pointer) : void {
// check if input is inside the board, given input position, and set "canDrag" accordingly
this.canDrag = this.dragAndMatch.isInputInsideBoard(pointer.position.x, pointer.position.y)
}
// input move callback
inputMove(pointer : Phaser.Input.Pointer) : void {
// can the player drag?
if (this.canDrag) {
// handle input movement using only starting and current coordinates
this.dragAndMatch.handleInputMovement(pointer.downX, pointer.downY, pointer.position.x, pointer.position.y).map((item) => {
// set sprite position
item.data.setPosition(item.posX, item.posY)
// set sprite proper frame according to item value
item.data.setFrame(item.value);
// is this the dumm item?
if (item.isDummy) {
// place dummy highlight graphic object
this.dummyHighlight.setPosition(item.data.x, item.data.y);
// show dummy highlight graphic object
this.dummyHighlight.setVisible(true);
// show dummy item
item.data.setVisible(true);
}
});
}
}
// input stop callback
inputStop(pointer : Phaser.Input.Pointer) : void {
// can the player drag?
if (this.canDrag) {
// player can't drag anymore
this.canDrag = false;
// handle input stop using only starting and current coordinates
this.dragAndMatch.handleInputStop(pointer.downX, pointer.downY, pointer.position.x, pointer.position.y).map((item) => {
// set item position
item.data.setPosition(item.posX, item.posY);
// is this the dummy item?
if (item.isDummy) {
// hide dummy item
item.data.setVisible(false);
// hide dummy highlight graphic object
this.dummyHighlight.setVisible(false);
}
});
// string to list the matches
let matchString : string = '';
// get all board matches
this.dragAndMatch.matches.map((match) => {
// update match string
matchString += (match.direction == 1 ? 'H' : 'V') + ' from (' + match.startRow + ',' + match.startColumn + ') to (' + match.endRow + ',' + match.endColumn + ') - length: ' + match.length + ' - item: ' + match.value + '\n';
})
// update info text
this.infoText.setText(matchString);
}
}
}
dragAndMatch.ts
The class responsible of everything. Some features are still missing, but I am going to add them during next days.
interface DragAndMatchConfig {
rows? : number;
columns? : number;
items? : number;
match? : number;
tileSize? : number;
startX? : number;
startY? : number;
minDragDistance? : number;
[otherOptions : string] : unknown;
}
interface GameConfig {
rows : number;
columns : number;
items : number;
match : number;
tileSize : number;
startX : number;
startY : number;
minDragDistance : number;
}
enum directionType {
NONE,
HORIZONTAL,
VERTICAL
}
interface DragAndMatchTile {
empty : boolean,
value : number
item : DragAndMatchItem,
}
interface DragAndMatchMatch {
startRow : number;
startColumn : number;
endRow : number;
endColumn : number;
direction : directionType;
length : number;
value : number;
}
export class DragAndMatch {
static readonly DEFALUT_VALUES : GameConfig = {
rows : 6,
columns : 8,
items : 6,
match : 3,
tileSize : 100,
startX : 0,
startY : 0,
minDragDistance : 20
}
config : GameConfig;
gameArray : DragAndMatchTile[][];
dragDirection : directionType;
dummyItem : DragAndMatchItem;
constructor(options? : DragAndMatchConfig) {
this.config = {
rows : (options === undefined || options.rows === undefined) ? DragAndMatch.DEFALUT_VALUES.rows : options.rows,
columns : (options === undefined || options.columns === undefined) ? DragAndMatch.DEFALUT_VALUES.columns : options.columns,
items : (options === undefined || options.items === undefined) ? DragAndMatch.DEFALUT_VALUES.items : options.items,
match : (options === undefined || options.match === undefined) ? DragAndMatch.DEFALUT_VALUES.match : options.match,
tileSize : (options === undefined || options.tileSize === undefined) ? DragAndMatch.DEFALUT_VALUES.tileSize : options.tileSize,
startX : (options === undefined || options.startX === undefined) ? DragAndMatch.DEFALUT_VALUES.startX : options.startX,
startY : (options === undefined || options.startY === undefined) ? DragAndMatch.DEFALUT_VALUES.startY : options.startY,
minDragDistance : (options === undefined || options.minDragDistance === undefined) ? DragAndMatch.DEFALUT_VALUES.minDragDistance : options.minDragDistance,
}
this.dragDirection = directionType.NONE;
this.gameArray = [];
for (let i : number = 0; i < this.config.rows; i ++) {
this.gameArray[i] = [];
for (let j : number = 0; j < this.config.columns; j ++) {
let randomValue : number = this.safeValue(i, j);
this.gameArray[i][j] = {
empty : false,
value : randomValue,
item : new DragAndMatchItem(i, j, randomValue, this.config.startX + j * this.config.tileSize, this.config.startY + i * this.config.tileSize, false)
}
}
}
this.dummyItem = new DragAndMatchItem(0, 0, 0, 0, 0, true);
}
/* generate a safe value, which can't return a match in the game */
private safeValue(row : number, column : number) : number {
let safeValues : number[] = Array.from(Array(this.config.items).keys());
if (row >= this.config.match - 1) {
let possibleMatch : boolean = true;
let prevValue : number = -1;
let value : number = -1;
for (let i : number = row - 1; i > row - this.config.match; i --) {
value = this.gameArray[i][column].value;
possibleMatch = possibleMatch && (value == prevValue || prevValue == -1);
prevValue = value;
}
if (possibleMatch) {
let index = safeValues.indexOf(value);
if (index > -1) {
safeValues.splice(index, 1);
}
}
}
if (column >= this.config.match - 1) {
let possibleMatch : boolean = true;
let prevValue : number = -1;
let value : number = -1;
for (let i : number = column - 1; i > column - this.config.match; i --) {
value = this.gameArray[row][i].value;
possibleMatch = possibleMatch && (value == prevValue || prevValue == -1);
prevValue = value;
}
if (possibleMatch) {
let index = safeValues.indexOf(value);
if (index > -1) {
safeValues.splice(index, 1);
}
}
}
return safeValues[Math.floor(Math.random() * safeValues.length)]
}
/* get all game items */
get items() : DragAndMatchItem[] {
let items : DragAndMatchItem[] = [];
for (let i : number = 0; i < this.config.rows; i ++) {
for (let j : number = 0; j < this.config.columns; j ++) {
items.push(this.gameArray[i][j].item);
}
}
items.push(this.dummyItem);
return items;
}
/* check if input is inside game board */
isInputInsideBoard(x : number, y : number) : boolean {
let column : number = Math.floor((x - this.config.startX) / this.config.tileSize);
let row : number = Math.floor((y - this.config.startY) / this.config.tileSize);
return this.validPick(row, column);
}
/* handle input movement */
handleInputMovement(startX : number, startY : number, currentX : number, currentY : number) : DragAndMatchItem[] {
let distanceX : number = currentX - startX;
if (Math.abs(distanceX % this.config.tileSize) == 0) {
distanceX += 0.001;
}
let distanceY : number = currentY - startY;
if (Math.abs(distanceY % this.config.tileSize) == 0) {
distanceY += 0.001;
}
if (this.dragDirection == directionType.NONE && Math.abs(distanceX) + Math.abs(distanceY) > this.config.minDragDistance) {
this.dragDirection = (Math.abs(distanceX) > Math.abs(distanceY)) ? directionType.HORIZONTAL : directionType.VERTICAL;
}
let items : DragAndMatchItem[] = [];
switch (this.dragDirection) {
case directionType.HORIZONTAL :
let row : number = Math.floor((startY - this.config.startY) / this.config.tileSize);
items = this.getItemsAtRow(row);
items.forEach((item) => {
let newPosX : number = item.column * this.config.tileSize + distanceX;
let limitX : number = this.config.columns * this.config.tileSize;
newPosX = newPosX >= 0 ? newPosX % limitX : (newPosX % limitX + limitX) % limitX;
item.posX = this.config.startX + newPosX;
});
this.dummyItem.posY = this.config.startY + row * this.config.tileSize;
this.dummyItem.posX = distanceX >= 0 ? (this.config.startX + Math.abs(distanceX) % this.config.tileSize - this.config.tileSize) : (this.config.startX - Math.abs(distanceX) % this.config.tileSize);
let columnOffset : number = Math.floor(distanceX / this.config.tileSize);
let newColumnReference : number = columnOffset >= 0 ? (this.config.columns - 1 - columnOffset % this.config.columns) : (((1 + columnOffset) * -1) % this.config.columns);
this.dummyItem.value = this.gameArray[row][newColumnReference].value;
items.push(this.dummyItem);
break;
case directionType.VERTICAL :
let column : number = Math.floor((startX - this.config.startX) / this.config.tileSize);
items = this.getItemsAtColumn(column);
items.forEach((item) => {
let newPosY : number = item.row * this.config.tileSize + distanceY;
let limitY : number = this.config.rows * this.config.tileSize;
newPosY = newPosY >= 0 ? newPosY % limitY : (newPosY % limitY + limitY) % limitY;
item.posY = this.config.startY + newPosY;
});
this.dummyItem.posX = this.config.startX + column * this.config.tileSize;
this.dummyItem.posY = distanceY >= 0 ? (this.config.startY + Math.abs(distanceY) % this.config.tileSize - this.config.tileSize) : (this.config.startY - Math.abs(distanceY) % this.config.tileSize);
let rowOffset : number = Math.floor(distanceY / this.config.tileSize);
let newRowReference : number = rowOffset >= 0 ? (this.config.rows - 1 - rowOffset % this.config.rows) : (((1 + rowOffset) * -1) % this.config.rows);
this.dummyItem.value = this.gameArray[newRowReference][column].value;
items.push(this.dummyItem);
break;
}
return items;
}
/* handle stop input movement */
handleInputStop(startX : number, startY : number, currentX : number, currentY : number) : DragAndMatchItem[] {
let items: DragAndMatchItem[] = [];
let tempItemArray : DragAndMatchItem[];
let tempValueArray : number[];
switch (this.dragDirection) {
case directionType.HORIZONTAL :
let row : number = Math.floor((startY - this.config.startY) / this.config.tileSize);
let distanceX : number = currentX - startX;
let columnOffset : number = Math.round(distanceX / this.config.tileSize);
tempItemArray = this.getItemsAtRow(row);
tempValueArray = this.getValuesAtRow(row);
for (let i : number = 0; i < this.config.columns; i ++) {
let destinationColumn : number = i - columnOffset;
let wrappedDestinationColumn : number = this.wrapValues(destinationColumn, this.config.columns);
let item : DragAndMatchItem = tempItemArray[wrappedDestinationColumn];
item.column = i;
item.posX = this.config.startX + i * this.config.tileSize;
this.gameArray[row][i].item = item;
this.gameArray[row][i].value = tempValueArray[wrappedDestinationColumn];
}
items = this.getItemsAtRow(row);
break;
case directionType.VERTICAL :
let column : number = Math.floor((startX - this.config.startX) / this.config.tileSize);
let distanceY : number = currentY - startY;
let rowOffset : number = Math.round(distanceY / this.config.tileSize);
tempItemArray = this.getItemsAtColumn(column);
tempValueArray = this.getValuesAtColumn(column);
for (let i : number = 0; i < this.config.rows; i ++) {
let destinationRow : number = i - rowOffset;
let wrappedDestinationRow : number = this.wrapValues(destinationRow, this.config.rows);
let item : DragAndMatchItem = tempItemArray[wrappedDestinationRow];
item.row = i;
item.posY = this.config.startY + i * this.config.tileSize;
this.gameArray[i][column].item = item;
this.gameArray[i][column].value = tempValueArray[wrappedDestinationRow];
}
items = this.getItemsAtColumn(column);
break;
}
items.push(this.dummyItem);
this.dragDirection = directionType.NONE;
return items;
}
/* just a function to wap values */
wrapValues(value : number, limit : number) : number {
return value >= 0 ? value % limit : (value % limit + limit) % limit;
}
/* get all items in a row */
getItemsAtRow(row : number) : DragAndMatchItem[] {
let items : DragAndMatchItem[] = [];
for (let i : number = 0; i < this.config.columns; i ++) {
items.push(this.gameArray[row][i].item as DragAndMatchItem);
}
return items;
}
/* get all values in a row */
getValuesAtRow(row : number) : number[] {
let values : number[] = [];
for (let i : number = 0; i < this.config.columns; i ++) {
values.push(this.gameArray[row][i].value);
}
return values;
}
/* get all items in a column */
getItemsAtColumn(column : number) : DragAndMatchItem[] {
let items : DragAndMatchItem[] = [];
for (let i : number = 0; i < this.config.rows; i ++) {
items.push(this.gameArray[i][column].item as DragAndMatchItem);
}
return items;
}
/* get all values in a column */
getValuesAtColumn(column : number) : number[] {
let values : number[] = [];
for (let i : number = 0; i < this.config.rows; i ++) {
values.push(this.gameArray[i][column].value);
}
return values;
}
/* check a pick is in a valid row and column */
validPick(row : number, column : number) : boolean {
return row >= 0 && row < this.config.rows && column >= 0 && column < this.config.columns && this.gameArray[row] != undefined && this.gameArray[row][column] != undefined;
}
/* get all matches */
get matches() : DragAndMatchMatch[] {
let matches : DragAndMatchMatch[] = [];
for (let i : number = 0; i < this.config.rows; i ++) {
matches = matches.concat(this.getHorizontalMatches(i));
}
for (let i : number = 0; i < this.config.columns; i ++) {
matches = matches.concat(this.getVerticalMatches(i));
}
return matches;
}
/* get all matches in a row */
getHorizontalMatches(row : number) : DragAndMatchMatch[] {
let matches : DragAndMatchMatch[] = [];
let combo : number = 1;
let previousValue : number = -1;
for (let i : number = 0; i < this.config.columns; i ++) {
let value : number = this.gameArray[row][i].value;
if (value == previousValue) {
combo ++;
}
if (value != previousValue || i == this.config.columns - 1) {
if (combo >= this.config.match) {
matches.push({
startRow : row,
startColumn : i - combo,
endRow : row,
endColumn : i - 1,
direction : directionType.HORIZONTAL,
length : combo,
value : previousValue
});
}
combo = 1;
previousValue = value;
}
}
return matches;
}
/* get all matches in a column */
getVerticalMatches(column : number) : DragAndMatchMatch[] {
let matches : DragAndMatchMatch[] = [];
let combo : number = 1;
let previousValue : number = -1;
for (let i : number = 0; i < this.config.rows; i ++) {
let value : number = this.gameArray[i][column].value;
if (value == previousValue) {
combo ++;
}
if (value != previousValue || i == this.config.rows - 1) {
if (combo >= this.config.match) {
matches.push({
startRow : i - combo,
startColumn : column,
endRow : i - 1,
endColumn : column,
direction : directionType.VERTICAL,
length : combo,
value : previousValue
})
}
combo = 1;
previousValue = value;
}
}
return matches;
}
}
class DragAndMatchItem {
row : number;
column : number;
value : number;
data : any;
posX : number;
posY : number;
isDummy : boolean;
constructor(row : number, column : number, value: number, posX: number, posY: number, isDummy : boolean) {
this.row = row;
this.column = column;
this.value = value;
this.posX = posX;
this.posY = posY;
this.isDummy = isDummy;
}
}
As you can see, thanks to this class it’s very easy to handle a Drag and Match game. Now I only have to add the code to remove matches and rearrange the board, and you will be able to build your Drag and Match engine in just a few lines. 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.