diff --git a/games/match3/bg.jpg b/games/match3/bg.jpg new file mode 100644 index 0000000..0a4150d Binary files /dev/null and b/games/match3/bg.jpg differ diff --git a/games/match3/font.ttf b/games/match3/font.ttf new file mode 100644 index 0000000..3729151 Binary files /dev/null and b/games/match3/font.ttf differ diff --git a/games/match3/game.js b/games/match3/game.js new file mode 100644 index 0000000..4a5ce92 --- /dev/null +++ b/games/match3/game.js @@ -0,0 +1,32 @@ +import Phaser from "phaser"; +import Scene from "./scene"; + +function launch({ containerId, onLose }) { + const container = document.getElementById(containerId); + const width = container.clientWidth; + const height = container.clientHeight; + const game = new Phaser.Game({ + type: Phaser.CANVAS, + width, + height, + parent: containerId, + scale: { + mode: Phaser.Scale.FIT, + autoCenter: Phaser.Scale.CENTER_BOTH, + parent: containerId, + width: 2160, + height: 2160, + }, + scene: Scene, + }); + Object.assign(game, { + onLose, + restart() { + this.scene.scenes[0].scene.start("scene"); + }, + }); + return game; +} + +export default launch; +export { launch }; diff --git a/games/match3/gems.json b/games/match3/gems.json new file mode 100644 index 0000000..792e967 --- /dev/null +++ b/games/match3/gems.json @@ -0,0 +1,146 @@ +{ + "textures": [ + { + "image": "gems.png", + "format": "RGBA8888", + "size": { + "w": 1428, + "h": 238 + }, + "scale": 1, + "frames": [ + { + "filename": "1.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 238, + "h": 238 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 238, + "h": 238 + }, + "frame": { + "x": 0, + "y": 0, + "w": 238, + "h": 238 + } + }, + { + "filename": "2.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 238, + "h": 238 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 238, + "h": 238 + }, + "frame": { + "x": 238, + "y": 0, + "w": 238, + "h": 238 + } + }, + { + "filename": "3.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 238, + "h": 238 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 238, + "h": 238 + }, + "frame": { + "x": 476, + "y": 0, + "w": 238, + "h": 238 + } + }, + { + "filename": "4.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 238, + "h": 238 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 238, + "h": 238 + }, + "frame": { + "x": 714, + "y": 0, + "w": 238, + "h": 238 + } + }, + { + "filename": "5.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 238, + "h": 238 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 238, + "h": 238 + }, + "frame": { + "x": 952, + "y": 0, + "w": 238, + "h": 238 + } + }, + { + "filename": "6.png", + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 238, + "h": 238 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 238, + "h": 238 + }, + "frame": { + "x": 1190, + "y": 0, + "w": 238, + "h": 238 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:eab6ef09769fec1413e2bf10cd7551b1:a2f99735c3306898d28ffc7880e0dbf4:81fc68276d1596f8f7ad75d59a9ce9b5$" + } +} diff --git a/games/match3/gems.png b/games/match3/gems.png new file mode 100644 index 0000000..9726ff0 Binary files /dev/null and b/games/match3/gems.png differ diff --git a/games/match3/match3.js b/games/match3/match3.js new file mode 100644 index 0000000..7f9c6f6 --- /dev/null +++ b/games/match3/match3.js @@ -0,0 +1,252 @@ +export default class Match3 { + // constructor, simply turns obj information into class properties + constructor(obj) { + this.rows = obj.rows; + this.columns = obj.columns; + this.items = obj.items; + } + + // generates the game field + generateField() { + this.gameArray = []; + this.selectedItem = false; + for (let i = 0; i < this.rows; i++) { + this.gameArray[i] = []; + for (let j = 0; j < this.columns; j++) { + do { + let randomValue = Math.floor(Math.random() * this.items); + this.gameArray[i][j] = { + value: randomValue, + isEmpty: false, + row: i, + column: j, + }; + } while (this.isPartOfMatch(i, j)); + } + } + } + + // returns true if there is a match in the board + matchInBoard() { + for (let i = 0; i < this.rows; i++) { + for (let j = 0; j < this.columns; j++) { + if (this.isPartOfMatch(i, j)) { + return true; + } + } + } + return false; + } + + // returns true if the item at (row, column) is part of a match + isPartOfMatch(row, column) { + return ( + this.isPartOfHorizontalMatch(row, column) || + this.isPartOfVerticalMatch(row, column) + ); + } + + // returns true if the item at (row, column) is part of an horizontal match + isPartOfHorizontalMatch(row, column) { + return ( + (this.valueAt(row, column) === this.valueAt(row, column - 1) && + this.valueAt(row, column) === this.valueAt(row, column - 2)) || + (this.valueAt(row, column) === this.valueAt(row, column + 1) && + this.valueAt(row, column) === this.valueAt(row, column + 2)) || + (this.valueAt(row, column) === this.valueAt(row, column - 1) && + this.valueAt(row, column) === this.valueAt(row, column + 1)) + ); + } + + // returns true if the item at (row, column) is part of an horizontal match + isPartOfVerticalMatch(row, column) { + return ( + (this.valueAt(row, column) === this.valueAt(row - 1, column) && + this.valueAt(row, column) === this.valueAt(row - 2, column)) || + (this.valueAt(row, column) === this.valueAt(row + 1, column) && + this.valueAt(row, column) === this.valueAt(row + 2, column)) || + (this.valueAt(row, column) === this.valueAt(row - 1, column) && + this.valueAt(row, column) === this.valueAt(row + 1, column)) + ); + } + + // returns the value of the item at (row, column), or false if it's not a valid pick + valueAt(row, column) { + if (!this.validPick(row, column)) { + return false; + } + return this.gameArray[row][column].value; + } + + // returns true if the item at (row, column) is a valid pick + validPick(row, column) { + return ( + row >= 0 && + row < this.rows && + column >= 0 && + column < this.columns && + this.gameArray[row] != undefined && + this.gameArray[row][column] != undefined + ); + } + + // returns the number of board rows + getRows() { + return this.rows; + } + + // returns the number of board columns + getColumns() { + return this.columns; + } + + // sets a custom data on the item at (row, column) + setCustomData(row, column, customData) { + this.gameArray[row][column].customData = customData; + } + + // returns the custom data of the item at (row, column) + customDataOf(row, column) { + return this.gameArray[row][column].customData; + } + + // returns the selected item + getSelectedItem() { + return this.selectedItem; + } + + // set the selected item as a {row, column} object + setSelectedItem(row, column) { + this.selectedItem = { + row: row, + column: column, + }; + } + + // deleselects any item + deleselectItem() { + this.selectedItem = false; + } + + // checks if the item at (row, column) is the same as the item at (row2, column2) + areTheSame(row, column, row2, column2) { + return row == row2 && column == column2; + } + + // returns true if two items at (row, column) and (row2, column2) are next to each other horizontally or vertically + areNext(row, column, row2, column2) { + return Math.abs(row - row2) + Math.abs(column - column2) == 1; + } + + // swap the items at (row, column) and (row2, column2) and returns an object with movement information + swapItems(row, column, row2, column2) { + let tempObject = Object.assign(this.gameArray[row][column]); + this.gameArray[row][column] = Object.assign(this.gameArray[row2][column2]); + this.gameArray[row2][column2] = Object.assign(tempObject); + return [ + { + row: row, + column: column, + deltaRow: row - row2, + deltaColumn: column - column2, + }, + { + row: row2, + column: column2, + deltaRow: row2 - row, + deltaColumn: column2 - column, + }, + ]; + } + + // return the items part of a match in the board as an array of {row, column} object + getMatchList() { + let matches = []; + for (let i = 0; i < this.rows; i++) { + for (let j = 0; j < this.columns; j++) { + if (this.isPartOfMatch(i, j)) { + matches.push({ + row: i, + column: j, + }); + } + } + } + return matches; + } + + // removes all items forming a match + removeMatches() { + let matches = this.getMatchList(); + matches.forEach( + function (item) { + this.setEmpty(item.row, item.column); + }.bind(this) + ); + } + + // set the item at (row, column) as empty + setEmpty(row, column) { + this.gameArray[row][column].isEmpty = true; + } + + // returns true if the item at (row, column) is empty + isEmpty(row, column) { + return this.gameArray[row][column].isEmpty; + } + + // returns the amount of empty spaces below the item at (row, column) + emptySpacesBelow(row, column) { + let result = 0; + if (row != this.getRows()) { + for (let i = row + 1; i < this.getRows(); i++) { + if (this.isEmpty(i, column)) { + result++; + } + } + } + return result; + } + + // arranges the board after a match, making items fall down. Returns an object with movement information + arrangeBoardAfterMatch() { + let result = []; + for (let i = this.getRows() - 2; i >= 0; i--) { + for (let j = 0; j < this.getColumns(); j++) { + let emptySpaces = this.emptySpacesBelow(i, j); + if (!this.isEmpty(i, j) && emptySpaces > 0) { + this.swapItems(i, j, i + emptySpaces, j); + result.push({ + row: i + emptySpaces, + column: j, + deltaRow: emptySpaces, + deltaColumn: 0, + }); + } + } + } + return result; + } + + // replenished the board and returns an object with movement information + replenishBoard() { + let result = []; + for (let i = 0; i < this.getColumns(); i++) { + if (this.isEmpty(0, i)) { + let emptySpaces = this.emptySpacesBelow(0, i) + 1; + for (let j = 0; j < emptySpaces; j++) { + let randomValue = Math.floor(Math.random() * this.items); + result.push({ + row: j, + column: i, + deltaRow: emptySpaces, + deltaColumn: 0, + }); + this.gameArray[j][i].value = randomValue; + this.gameArray[j][i].isEmpty = false; + } + } + } + return result; + } +} diff --git a/games/match3/scene.js b/games/match3/scene.js new file mode 100644 index 0000000..7a66128 --- /dev/null +++ b/games/match3/scene.js @@ -0,0 +1,268 @@ +import gems from "./gems.png"; +import Match3 from "./match3"; +import bg from "./bg.jpg"; +import "./style.css"; +const gameOptions = { + gemSize: 260, + frameSize: 238, + swapSpeed: 200, + fallSpeed: 100, + destroySpeed: 200, + boardOffset: { + x: 300, + y: 520, + }, +}; +export default class playGame extends Phaser.Scene { + constructor() { + super("scene"); + } + preload() { + this.load.image("bg", bg); + this.load.spritesheet("gems", gems, { + frameWidth: gameOptions.frameSize, + frameHeight: gameOptions.frameSize, + }); + } + updateTexts() { + this.score.text = this.scoreNum; + this.moves.text = this.moveNum; + } + create() { + const bg = this.add.image( + this.game.config.width / 2, + this.game.config.height / 2, + "bg" + ); + bg.displayWidth = this.game.config.width; + bg.displayHeight = this.game.config.height; + this.scoreNum = 0; + this.moveNum = 30; + this.score = this.add.text(700, 320, this.scoreNum, { + fontSize: 100, + fontFamily: "game", + color: "#3aab7d", + }); + this.moves = this.add.text(1552, 320, this.moveNum, { + fontSize: 100, + fontFamily: "game", + color: "#3aab7d", + }); + this.match3 = new Match3({ + rows: 6, + columns: 6, + items: 6, + }); + this.match3.generateField(); + this.canPick = true; + this.dragging = false; + this.drawField(); + this.input.on("pointerdown", this.gemSelect, this); + } + drawField() { + this.poolArray = []; + for (let i = 0; i < this.match3.getRows(); i++) { + for (let j = 0; j < this.match3.getColumns(); j++) { + let gemX = + gameOptions.boardOffset.x + + gameOptions.gemSize * j + + gameOptions.gemSize / 2; + let gemY = + gameOptions.boardOffset.y + + gameOptions.gemSize * i + + gameOptions.gemSize / 2; + let gem = this.add.sprite( + gemX, + gemY, + "gems", + this.match3.valueAt(i, j) + ); + this.match3.setCustomData(i, j, gem); + } + } + } + gemSelect(pointer) { + if (this.canPick) { + this.dragging = true; + let row = Math.floor( + (pointer.y - gameOptions.boardOffset.y) / gameOptions.gemSize + ); + let col = Math.floor( + (pointer.x - gameOptions.boardOffset.x) / gameOptions.gemSize + ); + if (this.match3.validPick(row, col)) { + let selectedGem = this.match3.getSelectedItem(); + if (!selectedGem) { + this.match3.customDataOf(row, col).setScale(1.2); + this.match3.customDataOf(row, col).setDepth(1); + this.match3.setSelectedItem(row, col); + } else { + if ( + this.match3.areTheSame( + row, + col, + selectedGem.row, + selectedGem.column + ) + ) { + this.match3.customDataOf(row, col).setScale(1); + this.match3.deleselectItem(); + } else { + if ( + this.match3.areNext(row, col, selectedGem.row, selectedGem.column) + ) { + this.match3 + .customDataOf(selectedGem.row, selectedGem.column) + .setScale(1); + this.match3.deleselectItem(); + if (this.moveNum === 0) { + return this.game.onLose && this.game.onLose(); + } + this.moveNum--; + this.updateTexts(); + this.swapGems( + row, + col, + selectedGem.row, + selectedGem.column, + true + ); + } else { + this.match3 + .customDataOf(selectedGem.row, selectedGem.column) + .setScale(1); + this.match3.customDataOf(row, col).setScale(1.2); + this.match3.setSelectedItem(row, col); + } + } + } + } + } + } + swapGems(row, col, row2, col2, swapBack) { + let movements = this.match3.swapItems(row, col, row2, col2); + this.swappingGems = 2; + this.canPick = false; + movements.forEach( + function (movement) { + this.tweens.add({ + targets: this.match3.customDataOf(movement.row, movement.column), + x: + this.match3.customDataOf(movement.row, movement.column).x + + gameOptions.gemSize * movement.deltaColumn, + y: + this.match3.customDataOf(movement.row, movement.column).y + + gameOptions.gemSize * movement.deltaRow, + duration: gameOptions.swapSpeed, + callbackScope: this, + onComplete: function () { + this.swappingGems--; + if (this.swappingGems == 0) { + if (!this.match3.matchInBoard()) { + if (swapBack) { + this.swapGems(row, col, row2, col2, false); + } else { + this.canPick = true; + } + } else { + this.handleMatches(); + } + } + }, + }); + }.bind(this) + ); + } + handleMatches() { + let gemsToRemove = this.match3.getMatchList(); + this.scoreNum += gemsToRemove.length * 10; + this.updateTexts(); + let destroyed = 0; + gemsToRemove.forEach( + function (gem) { + this.poolArray.push(this.match3.customDataOf(gem.row, gem.column)); + destroyed++; + this.tweens.add({ + targets: this.match3.customDataOf(gem.row, gem.column), + alpha: 0, + duration: gameOptions.destroySpeed, + callbackScope: this, + onComplete: function (event, sprite) { + destroyed--; + if (destroyed == 0) { + this.makeGemsFall(); + } + }, + }); + }.bind(this) + ); + } + makeGemsFall() { + let moved = 0; + this.match3.removeMatches(); + let fallingMovements = this.match3.arrangeBoardAfterMatch(); + fallingMovements.forEach( + function (movement) { + moved++; + this.tweens.add({ + targets: this.match3.customDataOf(movement.row, movement.column), + y: + this.match3.customDataOf(movement.row, movement.column).y + + movement.deltaRow * gameOptions.gemSize, + duration: gameOptions.fallSpeed * Math.abs(movement.deltaRow), + callbackScope: this, + onComplete: function () { + moved--; + if (moved == 0) { + this.endOfMove(); + } + }, + }); + }.bind(this) + ); + let replenishMovements = this.match3.replenishBoard(); + replenishMovements.forEach( + function (movement) { + moved++; + let sprite = this.poolArray.pop(); + sprite.alpha = 1; + sprite.y = + gameOptions.boardOffset.y + + gameOptions.gemSize * (movement.row - movement.deltaRow + 1) - + gameOptions.gemSize / 2; + (sprite.x = + gameOptions.boardOffset.x + + gameOptions.gemSize * movement.column + + gameOptions.gemSize / 2), + sprite.setFrame(this.match3.valueAt(movement.row, movement.column)); + this.match3.setCustomData(movement.row, movement.column, sprite); + this.tweens.add({ + targets: sprite, + y: + gameOptions.boardOffset.y + + gameOptions.gemSize * movement.row + + gameOptions.gemSize / 2, + duration: gameOptions.fallSpeed * movement.deltaRow, + callbackScope: this, + onComplete: function () { + moved--; + if (moved == 0) { + this.endOfMove(); + } + }, + }); + }.bind(this) + ); + } + endOfMove() { + if (this.match3.matchInBoard()) { + this.time.addEvent({ + delay: 250, + callback: this.handleMatches(), + }); + } else { + this.canPick = true; + this.selectedGem = null; + } + } +} diff --git a/games/match3/style.css b/games/match3/style.css new file mode 100644 index 0000000..1a6b247 --- /dev/null +++ b/games/match3/style.css @@ -0,0 +1,4 @@ +@font-face { + font-family: "game"; + src: url("./font.ttf") format("truetype"); +} diff --git a/phaser.js b/phaser.js index 5e72bde..31ba49a 100644 --- a/phaser.js +++ b/phaser.js @@ -4,6 +4,7 @@ import game2048 from "./games/game2048/game"; import rect from "./games/game-rect/game"; import floodFill from "./games/game-flood-fill/game"; import fishMaster from "./games/fishMaster/index"; +import match3 from "./games/match3/game"; const attachMethods = (fn) => (...p) => Object.assign(fn(...p), { pause() { @@ -33,6 +34,7 @@ const games = Object.entries({ rect, floodFill, fishMaster, + match3, }) .map(([name, fn]) => ({ [name]: attachMethods(fn) })) .reduce((acc, nxt) => Object.assign(acc, nxt), {});