A class to manage game mechanics should be complete enough to let the player only have to manage player input and graphics output, just like I did in my Sokoban TypeScript class which allows you to build a complete Sokoban playable game in less than 10 lines of code.
Things get a little more difficult when trying to write a class for a Drag and Match game, and although I already published a pure JavaScript class to handle Drag and Match games, there still was a lot of code to write to turn it into a completely playable game.
So, instead of simply translating the JavaScript class to TypeScript, I decided to rewrite it almost from scratch, leaving to game developers as less lines to write as possible, while keeping a lot of room for customization.
At the moment this prototype only allows to create the game field and drag rows or columns, but you can do it in about 15 lines of code, which is really interesting.
First, have a look at what we are going to build:
Try to drag rows and columns and see what happens.
The red rectangle represents the game area, while the green square highlights the dummy item which is used to make items wrap around rows and columns without actually wrapping them.
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.
Finally handleInputStop method, given start input coordinate an current input coordinate, calculate item final destination and updated the game board, returning all item information.
These three methods are all you need at the moment, look at them in action in the source code.
While main files are already commented, the class is provided without comments because I still need to optimize it.
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 : 800 } // 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; // graphics object to highlight dummy item dummyHighlight : Phaser.GameObjects.Graphics; // constructor constructor() { super({ key: 'PlayGame' }); } create() : void { // 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); } }); } } }
dragAndMatch.ts
The class responsible of everything. Not that optimized at the moment, but with great potential in my opinion.
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, } 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; let distanceY : number = currentY - startY; 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; } } 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; } }
Now, while I continue to work on the class, I am publishing more examples using different frameworks. Meanwhile, download the source code of the entire project.