├── .github └── workflows │ └── main.yml ├── .gitignore ├── README.md ├── index.html ├── package.json ├── src ├── scripts │ ├── game │ │ ├── Board.js │ │ ├── CombinationManager.js │ │ ├── Config.js │ │ ├── Field.js │ │ ├── Game.js │ │ ├── Tile.js │ │ └── TileFactory.js │ ├── index.js │ └── system │ │ ├── App.js │ │ ├── Loader.js │ │ └── Tools.js └── sprites │ ├── bg.png │ ├── blue.png │ ├── field-selected.png │ ├── field.png │ ├── green.png │ ├── orange.png │ ├── pink.png │ ├── red.png │ └── yellow.png └── webpack ├── base.js └── prod.js /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | workflow_dispatch: 9 | 10 | permissions: 11 | contents: read 12 | pages: write 13 | id-token: write 14 | 15 | jobs: 16 | deploy: 17 | environment: 18 | name: github-pages 19 | url: ${{ steps.deployment.outputs.page_url }} 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v3 23 | 24 | - name: Install Node.js 25 | uses: actions/setup-node@v3 26 | 27 | - name: Install dependencies 28 | run: npm i 29 | 30 | - name: Build 31 | run: npm run build 32 | 33 | - name: Upload page artifacts 34 | uses: actions/upload-pages-artifact@v1 35 | with: 36 | path: dist 37 | 38 | - name: Deploy to GitHub Pages 39 | id: deployment 40 | uses: actions/deploy-pages@main -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #///////////////////////////////////////////////////////////////////////////// 2 | # Fireball Projects 3 | #///////////////////////////////////////////////////////////////////////////// 4 | 5 | library/ 6 | temp/ 7 | local/ 8 | build/ 9 | dist/ 10 | releases/ 11 | 12 | #///////////////////////////////////////////////////////////////////////////// 13 | # npm files 14 | #///////////////////////////////////////////////////////////////////////////// 15 | 16 | npm-debug.log 17 | node_modules/ 18 | 19 | #///////////////////////////////////////////////////////////////////////////// 20 | # Logs and databases 21 | #///////////////////////////////////////////////////////////////////////////// 22 | 23 | *.log 24 | *.sql 25 | *.sqlite 26 | 27 | #///////////////////////////////////////////////////////////////////////////// 28 | # Videos 29 | #///////////////////////////////////////////////////////////////////////////// 30 | 31 | *.mp4 32 | 33 | #///////////////////////////////////////////////////////////////////////////// 34 | # files for debugger 35 | #///////////////////////////////////////////////////////////////////////////// 36 | 37 | *.sln 38 | *.csproj 39 | *.pidb 40 | *.unityproj 41 | *.suo 42 | 43 | #///////////////////////////////////////////////////////////////////////////// 44 | # OS generated files 45 | #///////////////////////////////////////////////////////////////////////////// 46 | 47 | .DS_Store 48 | ehthumbs.db 49 | Thumbs.db 50 | 51 | #///////////////////////////////////////////////////////////////////////////// 52 | # WebStorm files 53 | #///////////////////////////////////////////////////////////////////////////// 54 | 55 | .idea/ 56 | 57 | #////////////////////////// 58 | # VS Code files 59 | #////////////////////////// 60 | 61 | .vscode/ 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Match3 game with PixiJS 2 | 3 | Tutorial: https://gamedev.land/match3/ 4 | 5 | Preview demo: https://gamedevland.github.io/match3/ 6 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "match3", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "webpack --config webpack/prod.js ", 8 | "start": "webpack-dev-server --config webpack/base.js --open" 9 | }, 10 | "dependencies": { 11 | "gsap": "^3.10.4", 12 | "pixi.js": "^6.5.1" 13 | }, 14 | "devDependencies": { 15 | "babel-loader": "^8.2.5", 16 | "clean-webpack-plugin": "^4.0.0", 17 | "file-loader": "^6.2.0", 18 | "html-webpack-plugin": "^5.5.0", 19 | "webpack-cli": "^4.10.0", 20 | "webpack-dev-server": "^4.9.3", 21 | "webpack-merge": "^5.8.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/scripts/game/Board.js: -------------------------------------------------------------------------------- 1 | import * as PIXI from "pixi.js"; 2 | import { App } from "../system/App"; 3 | import { Field } from "./Field"; 4 | import { Tile } from "./Tile"; 5 | import { TileFactory } from "./TileFactory"; 6 | 7 | export class Board { 8 | constructor() { 9 | this.container = new PIXI.Container(); 10 | 11 | this.fields = []; 12 | this.rows = App.config.board.rows; 13 | this.cols = App.config.board.cols; 14 | this.create(); 15 | this.ajustPosition(); 16 | } 17 | 18 | create() { 19 | this.createFields(); 20 | this.createTiles(); 21 | } 22 | 23 | createTiles() { 24 | this.fields.forEach(field => this.createTile(field)); 25 | } 26 | 27 | createTile(field) { 28 | const tile = TileFactory.generate(); 29 | field.setTile(tile); 30 | this.container.addChild(tile.sprite); 31 | 32 | tile.sprite.interactive = true; 33 | tile.sprite.on("pointerdown", () => { 34 | this.container.emit('tile-touch-start', tile); 35 | }); 36 | 37 | return tile; 38 | } 39 | 40 | getField(row, col) { 41 | return this.fields.find(field => field.row === row && field.col === col); 42 | } 43 | 44 | createFields() { 45 | for (let row = 0; row < this.rows; row++) { 46 | for (let col = 0; col < this.cols; col++) { 47 | this.createField(row, col); 48 | } 49 | } 50 | } 51 | createField(row, col) { 52 | const field = new Field(row, col); 53 | this.fields.push(field); 54 | this.container.addChild(field.sprite); 55 | } 56 | 57 | ajustPosition() { 58 | this.fieldSize = this.fields[0].sprite.width; 59 | this.width = this.cols * this.fieldSize; 60 | this.height = this.rows * this.fieldSize; 61 | this.container.x = (window.innerWidth - this.width) / 2 + this.fieldSize / 2; 62 | this.container.y = (window.innerHeight - this.height) / 2 + this.fieldSize / 2; 63 | } 64 | 65 | swap(tile1, tile2) { 66 | const tile1Field = tile1.field; 67 | const tile2Field = tile2.field; 68 | 69 | tile1Field.tile = tile2; 70 | tile2.field = tile1Field; 71 | 72 | tile2Field.tile = tile1; 73 | tile1.field = tile2Field; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/scripts/game/CombinationManager.js: -------------------------------------------------------------------------------- 1 | import { App } from "../system/App"; 2 | 3 | export class CombinationManager { 4 | constructor(board) { 5 | this.board = board; 6 | } 7 | 8 | getMatches() { 9 | let result = []; 10 | 11 | this.board.fields.forEach(checkingField => { 12 | App.config.combinationRules.forEach(rule => { 13 | let matches = [checkingField.tile]; 14 | 15 | rule.forEach(position => { 16 | const row = checkingField.row + position.row; 17 | const col = checkingField.col + position.col; 18 | const comparingField = this.board.getField(row, col); 19 | if (comparingField && comparingField.tile.color === checkingField.tile.color) { 20 | matches.push(comparingField.tile); 21 | } 22 | }); 23 | 24 | if (matches.length === rule.length + 1) { 25 | result.push(matches); 26 | } 27 | }); 28 | }); 29 | 30 | return result; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/scripts/game/Config.js: -------------------------------------------------------------------------------- 1 | import { Game } from "./Game"; 2 | import { Tools } from "../system/Tools"; 3 | 4 | export const Config = { 5 | loader: Tools.massiveRequire(require["context"]('./../../sprites/', true, /\.(mp3|png|jpe?g)$/)), 6 | startScene: Game, 7 | tilesColors: ['blue', 'green', 'orange', 'red', 'pink', 'yellow'], 8 | board: { 9 | rows: 8, 10 | cols: 8 11 | }, 12 | combinationRules: [[ 13 | {col: 1, row: 0}, {col: 2, row: 0}, 14 | ], [ 15 | {col: 0, row: 1}, {col: 0, row: 2}, 16 | ]] 17 | }; -------------------------------------------------------------------------------- /src/scripts/game/Field.js: -------------------------------------------------------------------------------- 1 | import { App } from "../system/App"; 2 | 3 | export class Field { 4 | constructor(row, col) { 5 | this.row = row; 6 | this.col = col; 7 | 8 | this.sprite = App.sprite("field"); 9 | this.sprite.x = this.position.x; 10 | this.sprite.y = this.position.y; 11 | this.sprite.anchor.set(0.5); 12 | 13 | this.selected = App.sprite("field-selected"); 14 | this.sprite.addChild(this.selected); 15 | this.selected.visible = false; 16 | this.selected.anchor.set(0.5); 17 | 18 | } 19 | 20 | unselect() { 21 | this.selected.visible = false; 22 | } 23 | 24 | select() { 25 | this.selected.visible = true; 26 | } 27 | 28 | get position() { 29 | return { 30 | x: this.col * this.sprite.width, 31 | y: this.row * this.sprite.height 32 | }; 33 | } 34 | 35 | setTile(tile) { 36 | this.tile = tile; 37 | tile.field = this; 38 | tile.setPosition(this.position); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/scripts/game/Game.js: -------------------------------------------------------------------------------- 1 | import * as PIXI from "pixi.js"; 2 | import { App } from "../system/App"; 3 | import { Board } from "./Board"; 4 | import { CombinationManager } from "./CombinationManager"; 5 | 6 | export class Game { 7 | constructor() { 8 | this.container = new PIXI.Container(); 9 | this.createBackground(); 10 | 11 | this.board = new Board(); 12 | this.container.addChild(this.board.container); 13 | 14 | this.board.container.on('tile-touch-start', this.onTileClick.bind(this)); 15 | 16 | this.combinationManager = new CombinationManager(this.board); 17 | this.removeStartMatches(); 18 | } 19 | 20 | removeStartMatches() { 21 | let matches = this.combinationManager.getMatches(); 22 | 23 | while(matches.length) { 24 | this.removeMatches(matches); 25 | 26 | const fields = this.board.fields.filter(field => field.tile === null); 27 | 28 | fields.forEach(field => { 29 | this.board.createTile(field); 30 | }); 31 | 32 | matches = this.combinationManager.getMatches(); 33 | } 34 | } 35 | 36 | createBackground() { 37 | this.bg = App.sprite("bg"); 38 | this.bg.width = window.innerWidth; 39 | this.bg.height = window.innerHeight; 40 | this.container.addChild(this.bg); 41 | } 42 | 43 | onTileClick(tile) { 44 | if (this.disabled) { 45 | return; 46 | } 47 | if (this.selectedTile) { 48 | // select new tile or make swap 49 | if (!this.selectedTile.isNeighbour(tile)) { 50 | this.clearSelection(tile); 51 | this.selectTile(tile); 52 | } else { 53 | this.swap(this.selectedTile, tile); 54 | } 55 | 56 | 57 | } else { 58 | this.selectTile(tile); 59 | } 60 | } 61 | 62 | swap(selectedTile, tile, reverse) { 63 | this.disabled = true; 64 | selectedTile.sprite.zIndex = 2; 65 | 66 | selectedTile.moveTo(tile.field.position, 0.2); 67 | 68 | this.clearSelection(); 69 | 70 | tile.moveTo(selectedTile.field.position, 0.2).then(() => { 71 | this.board.swap(selectedTile, tile); 72 | 73 | if (!reverse) { 74 | const matches = this.combinationManager.getMatches(); 75 | if (matches.length) { 76 | this.processMatches(matches); 77 | } else { 78 | this.swap(tile, selectedTile, true); 79 | } 80 | } else { 81 | this.disabled = false; 82 | } 83 | }); 84 | } 85 | 86 | removeMatches(matches) { 87 | matches.forEach(match => { 88 | match.forEach(tile => { 89 | tile.remove(); 90 | }); 91 | }); 92 | } 93 | 94 | processMatches(matches) { 95 | this.removeMatches(matches); 96 | this.processFallDown() 97 | .then(() => this.addTiles()) 98 | .then(() => this.onFallDownOver()); 99 | } 100 | 101 | onFallDownOver() { 102 | const matches = this.combinationManager.getMatches(); 103 | 104 | if (matches.length) { 105 | this.processMatches(matches) 106 | } else { 107 | this.disabled = false; 108 | } 109 | } 110 | 111 | addTiles() { 112 | return new Promise(resolve => { 113 | const fields = this.board.fields.filter(field => field.tile === null); 114 | let total = fields.length; 115 | let completed = 0; 116 | 117 | fields.forEach(field => { 118 | const tile = this.board.createTile(field); 119 | tile.sprite.y = -500; 120 | const delay = Math.random() * 2 / 10 + 0.3 / (field.row + 1); 121 | tile.fallDownTo(field.position, delay).then(() => { 122 | ++completed; 123 | if (completed >= total) { 124 | resolve(); 125 | } 126 | }); 127 | }); 128 | });`` 129 | } 130 | 131 | processFallDown() { 132 | return new Promise(resolve => { 133 | let completed = 0; 134 | let started = 0; 135 | 136 | for (let row = this.board.rows - 1; row >= 0; row--) { 137 | for (let col = this.board.cols - 1; col >= 0; col--) { 138 | const field = this.board.getField(row, col); 139 | 140 | if (!field.tile) { 141 | ++started; 142 | this.fallDownTo(field).then(() => { 143 | ++completed; 144 | if (completed >= started) { 145 | resolve(); 146 | } 147 | }); 148 | } 149 | } 150 | } 151 | }); 152 | } 153 | 154 | fallDownTo(emptyField) { 155 | for (let row = emptyField.row - 1; row >= 0; row--) { 156 | let fallingField = this.board.getField(row, emptyField.col); 157 | 158 | if (fallingField.tile) { 159 | const fallingTile = fallingField.tile; 160 | fallingTile.field = emptyField; 161 | emptyField.tile = fallingTile; 162 | fallingField.tile = null; 163 | return fallingTile.fallDownTo(emptyField.position); 164 | } 165 | } 166 | 167 | return Promise.resolve(); 168 | } 169 | 170 | clearSelection() { 171 | if (this.selectedTile) { 172 | this.selectedTile.field.unselect(); 173 | this.selectedTile = null; 174 | } 175 | } 176 | 177 | selectTile(tile) { 178 | this.selectedTile = tile; 179 | this.selectedTile.field.select(); 180 | } 181 | } -------------------------------------------------------------------------------- /src/scripts/game/Tile.js: -------------------------------------------------------------------------------- 1 | import { gsap } from "gsap"; 2 | import { App } from "../system/App"; 3 | 4 | export class Tile { 5 | constructor(color) { 6 | this.color = color; 7 | this.sprite = App.sprite(this.color); 8 | this.sprite.anchor.set(0.5); 9 | } 10 | 11 | setPosition(position) { 12 | this.sprite.x = position.x; 13 | this.sprite.y = position.y; 14 | } 15 | 16 | moveTo(position, duration, delay, ease) { 17 | return new Promise(resolve => { 18 | gsap.to(this.sprite, { 19 | duration, 20 | delay, 21 | ease, 22 | pixi: { 23 | x: position.x, 24 | y: position.y 25 | }, 26 | onComplete: () => { 27 | resolve() 28 | } 29 | }); 30 | }); 31 | } 32 | isNeighbour(tile) { 33 | return Math.abs(this.field.row - tile.field.row) + Math.abs(this.field.col - tile.field.col) === 1 34 | } 35 | 36 | remove() { 37 | if (!this.sprite) { 38 | return; 39 | } 40 | this.sprite.destroy(); 41 | this.sprite = null; 42 | if (this.field) { 43 | this.field.tile = null; 44 | this.field = null; 45 | } 46 | } 47 | 48 | fallDownTo(position, delay) { 49 | return this.moveTo(position, 0.5, delay, "bounce.out"); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/scripts/game/TileFactory.js: -------------------------------------------------------------------------------- 1 | import { App } from "../system/App"; 2 | import { Tools } from "../system/Tools"; 3 | import { Tile } from "./Tile"; 4 | 5 | 6 | export class TileFactory { 7 | static generate() { 8 | const color = App.config.tilesColors[Tools.randomNumber(0, App.config.tilesColors.length - 1)]; 9 | return new Tile(color); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/scripts/index.js: -------------------------------------------------------------------------------- 1 | import { App } from "./system/App"; 2 | import { Config } from "./game/Config"; 3 | 4 | App.run(Config); 5 | -------------------------------------------------------------------------------- /src/scripts/system/App.js: -------------------------------------------------------------------------------- 1 | import * as PIXI from "pixi.js"; 2 | import { gsap } from "gsap"; 3 | import { PixiPlugin } from "gsap/PixiPlugin"; 4 | import { Loader } from "./Loader"; 5 | 6 | class Application { 7 | run(config) { 8 | gsap.registerPlugin(PixiPlugin); 9 | PixiPlugin.registerPIXI(PIXI); 10 | 11 | this.config = config; 12 | 13 | this.app = new PIXI.Application({resizeTo: window}); 14 | document.body.appendChild(this.app.view); 15 | 16 | this.loader = new Loader(this.app.loader, this.config); 17 | this.loader.preload().then(() => this.start()); 18 | } 19 | 20 | res(key) { 21 | return this.loader.resources[key].texture; 22 | } 23 | 24 | sprite(key) { 25 | return new PIXI.Sprite(this.res(key)); 26 | } 27 | 28 | start() { 29 | this.scene = new this.config["startScene"](); 30 | this.app.stage.addChild(this.scene.container); 31 | } 32 | } 33 | 34 | export const App = new Application(); 35 | -------------------------------------------------------------------------------- /src/scripts/system/Loader.js: -------------------------------------------------------------------------------- 1 | export class Loader { 2 | constructor(loader, config) { 3 | this.loader = loader; 4 | this.config = config; 5 | this.resources = {}; 6 | } 7 | 8 | preload() { 9 | for (const asset of this.config.loader) { 10 | let key = asset.key.substr(asset.key.lastIndexOf('/') + 1); 11 | key = key.substring(0, key.indexOf('.')); 12 | if (asset.key.indexOf(".png") !== -1 || asset.key.indexOf(".jpg") !== -1) { 13 | this.loader.add(key, asset.data.default) 14 | } 15 | } 16 | 17 | return new Promise(resolve => { 18 | this.loader.load((loader, resources) => { 19 | this.resources = resources; 20 | resolve(); 21 | }); 22 | }); 23 | } 24 | } -------------------------------------------------------------------------------- /src/scripts/system/Tools.js: -------------------------------------------------------------------------------- 1 | export class Tools { 2 | static randomNumber(min, max) { 3 | if (!max) { 4 | max = min; 5 | min = 0; 6 | } 7 | 8 | return Math.floor(Math.random() * (max - min + 1) + min); 9 | } 10 | 11 | static massiveRequire(req) { 12 | const files = []; 13 | 14 | req.keys().forEach(key => { 15 | files.push({ 16 | key, data: req(key) 17 | }); 18 | }); 19 | 20 | return files; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/sprites/bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gamedevland/match3/9ee54c3bc2e2ca737063997721eba53e56397707/src/sprites/bg.png -------------------------------------------------------------------------------- /src/sprites/blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gamedevland/match3/9ee54c3bc2e2ca737063997721eba53e56397707/src/sprites/blue.png -------------------------------------------------------------------------------- /src/sprites/field-selected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gamedevland/match3/9ee54c3bc2e2ca737063997721eba53e56397707/src/sprites/field-selected.png -------------------------------------------------------------------------------- /src/sprites/field.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gamedevland/match3/9ee54c3bc2e2ca737063997721eba53e56397707/src/sprites/field.png -------------------------------------------------------------------------------- /src/sprites/green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gamedevland/match3/9ee54c3bc2e2ca737063997721eba53e56397707/src/sprites/green.png -------------------------------------------------------------------------------- /src/sprites/orange.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gamedevland/match3/9ee54c3bc2e2ca737063997721eba53e56397707/src/sprites/orange.png -------------------------------------------------------------------------------- /src/sprites/pink.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gamedevland/match3/9ee54c3bc2e2ca737063997721eba53e56397707/src/sprites/pink.png -------------------------------------------------------------------------------- /src/sprites/red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gamedevland/match3/9ee54c3bc2e2ca737063997721eba53e56397707/src/sprites/red.png -------------------------------------------------------------------------------- /src/sprites/yellow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gamedevland/match3/9ee54c3bc2e2ca737063997721eba53e56397707/src/sprites/yellow.png -------------------------------------------------------------------------------- /webpack/base.js: -------------------------------------------------------------------------------- 1 | const webpack = require("webpack"); 2 | const path = require("path"); 3 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 4 | const { CleanWebpackPlugin } = require("clean-webpack-plugin"); 5 | 6 | module.exports = { 7 | mode: "development", 8 | devtool: "eval-source-map", 9 | entry: "./src/scripts/index.js", 10 | module: { 11 | rules: [ 12 | { 13 | test: /\.js$/, 14 | exclude: /node_modules/, 15 | use: { 16 | loader: "babel-loader" 17 | } 18 | }, 19 | { 20 | test: /\.(png|mp3|jpe?g)$/i, 21 | use: "file-loader" 22 | } 23 | ] 24 | }, 25 | plugins: [ 26 | new CleanWebpackPlugin({ 27 | root: path.resolve(__dirname, "../") 28 | }), 29 | new webpack.DefinePlugin({ 30 | CANVAS_RENDERER: JSON.stringify(true), 31 | WEBGL_RENDERER: JSON.stringify(true) 32 | }), 33 | new HtmlWebpackPlugin({ 34 | template: "./index.html" 35 | }) 36 | ] 37 | }; 38 | -------------------------------------------------------------------------------- /webpack/prod.js: -------------------------------------------------------------------------------- 1 | const merge = require("webpack-merge"); 2 | const path = require("path"); 3 | const base = require("./base"); 4 | const TerserPlugin = require("terser-webpack-plugin"); 5 | 6 | module.exports = merge.merge(base, { 7 | mode: "production", 8 | output: { 9 | filename: "bundle.min.js" 10 | }, 11 | devtool: false, 12 | performance: { 13 | maxEntrypointSize: 900000, 14 | maxAssetSize: 900000 15 | }, 16 | optimization: { 17 | minimizer: [ 18 | new TerserPlugin({ 19 | terserOptions: { 20 | output: { 21 | comments: false 22 | } 23 | } 24 | }) 25 | ] 26 | } 27 | }); 28 | --------------------------------------------------------------------------------