diff --git a/.github/workflows/test.yml-template b/.github/workflows/test.yml-template new file mode 100644 index 000000000..44ac4e963 --- /dev/null +++ b/.github/workflows/test.yml-template @@ -0,0 +1,29 @@ +name: Test + +on: + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [20.x] + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm start & sleep 5 && npm test + - name: Upload tests report(cypress mochaawesome merged HTML report) + if: ${{ always() }} + uses: actions/upload-artifact@v2 + with: + name: report + path: reports diff --git a/README.md b/README.md index 5aab92544..46b870730 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Okay, okay. Also, we have some rules: 7) When 2048 value is displayed in any cell, win message should be shown. 8) The `game over` message should be shown if there are no more available moves. 9) Hide start message when game starts. -10) Change the `Start` button to `Restart` after the first move. +10) Change the `Start` button to `Restart` after click on `Start` button. 11) `Restart` button should reset the game to the initial state. 12) Increase score with each move. The score should be increased by the sum of all merged cells. 13) The game consists of 2 main parts: @@ -61,7 +61,7 @@ You can change the HTML/CSS layout if you need it. ## Deploy and Pull Request 1. Replace `` with your Github username in the link - - [DEMO LINK](https://.github.io/js_2048_game/) + - [DEMO LINK](https://Razsinxron.github.io/js_2048_game/) 2. Follow [this instructions](https://mate-academy.github.io/layout_task-guideline/) - Run `npm run test` command to test your code; - Run `npm run test:only -- -n` to run fast test ignoring linter; diff --git a/package-lock.json b/package-lock.json index f209cb6e0..ff37dc85b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@mate-academy/eslint-config": "latest", "@mate-academy/jest-mochawesome-reporter": "^1.0.0", "@mate-academy/linthtml-config": "latest", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^1.9.12", "@mate-academy/stylelint-config": "latest", "@parcel/transformer-sass": "^2.12.0", "cypress": "^13.13.0", @@ -1467,10 +1467,11 @@ "dev": true }, "node_modules/@mate-academy/scripts": { - "version": "1.8.5", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.5.tgz", - "integrity": "sha512-mHRY2FkuoYCf5U0ahIukkaRo5LSZsxrTSgMJheFoyf3VXsTvfM9OfWcZIDIDB521kdPrScHHnRp+JRNjCfUO5A==", + "version": "1.9.12", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.9.12.tgz", + "integrity": "sha512-/OcmxMa34lYLFlGx7Ig926W1U1qjrnXbjFJ2TzUcDaLmED+A5se652NcWwGOidXRuMAOYLPU2jNYBEkKyXrFJA==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", diff --git a/package.json b/package.json index 0335978ca..05abe81e0 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "@mate-academy/eslint-config": "latest", "@mate-academy/jest-mochawesome-reporter": "^1.0.0", "@mate-academy/linthtml-config": "latest", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^1.9.12", "@mate-academy/stylelint-config": "latest", "@parcel/transformer-sass": "^2.12.0", "cypress": "^13.13.0", diff --git a/src/index.html b/src/index.html index aff3d1a98..586e3f8d2 100644 --- a/src/index.html +++ b/src/index.html @@ -11,6 +11,7 @@ rel="stylesheet" href="./styles/main.scss" /> +
@@ -25,37 +26,39 @@

2048

- - - - - - - - +
+
+ + + + + + + - - - - - - + + + + + + - - - - - - + + + + + + - - - - - - - -
+ + + + + + + + +
@@ -65,6 +68,9 @@

2048

- + diff --git a/src/modules/Game.class.js b/src/modules/Game.class.js index 65cd219c9..3cdee4126 100644 --- a/src/modules/Game.class.js +++ b/src/modules/Game.class.js @@ -1,68 +1,523 @@ 'use strict'; -/** - * This class represents the game. - * Now it has a basic structure, that is needed for testing. - * Feel free to add more props and methods if needed. - */ class Game { - /** - * Creates a new game instance. - * - * @param {number[][]} initialState - * The initial state of the board. - * @default - * [[0, 0, 0, 0], - * [0, 0, 0, 0], - * [0, 0, 0, 0], - * [0, 0, 0, 0]] - * - * If passed, the board will be initialized with the provided - * initial state. - */ - constructor(initialState) { - // eslint-disable-next-line no-console - console.log(initialState); - } - - moveLeft() {} - moveRight() {} - moveUp() {} - moveDown() {} - - /** - * @returns {number} - */ - getScore() {} - - /** - * @returns {number[][]} - */ - getState() {} - - /** - * Returns the current game status. - * - * @returns {string} One of: 'idle', 'playing', 'win', 'lose' - * - * `idle` - the game has not started yet (the initial state); - * `playing` - the game is in progress; - * `win` - the game is won; - * `lose` - the game is lost - */ - getStatus() {} - - /** - * Starts the game. - */ - start() {} - - /** - * Resets the game. - */ - restart() {} - - // Add your own methods here + constructor() { + this.initialState = [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]; + + this.startButton = document.querySelector('.button'); + this.scoreboard = document.querySelector('.game-score'); + this.board = []; + this.cellHistory = []; + this.status = 'idle'; + } + + move(direction) { + if (this.getStatus() === 'playing') { + this.cellHistory = []; + + const initBoard = JSON.stringify(this.board); + + switch (direction) { + case 'left': + this.moveLeft(this.board); + break; + case 'right': + this.moveRight(this.board); + break; + case 'up': + this.moveUp(this.board); + break; + case 'down': + this.moveDown(this.board); + break; + } + + if (JSON.stringify(this.board) !== initBoard) { + this.addRandomCell(); + this.displayGame(); + } else { + const checkboard = JSON.parse(JSON.stringify(this.board)); + const emptyCell = this.getEmptyCell(); + + this.moveLeft(checkboard); + this.moveRight(checkboard); + this.moveUp(checkboard); + this.moveDown(checkboard); + if(!emptyCell.length) { + if (JSON.stringify(checkboard) === initBoard) { + this.setStatus('lose'); + } + } + } + } + } + + // #region Move Cells + + moveLeft(board) { + for (let y = 0; y < board.length; y++) { + let index = 0; + + for (let x = 0; x < board.length; x++) { + if (board[y][x] !== 0) { + const cellValue = board[y][x]; + + if (index !== x) { + this.cellHistory.push({ + value: cellValue, + oldCoords: { + X: x, + Y: y, + }, + newCoords: { + X: index, + Y: y, + }, + move: true, + }); + + board[y][index] = cellValue; + board[y][x] = 0; + } + + if (index > 0 && board[y][index] === board[y][index - 1]) { + const mergeValue = board[y][index] * 2; + const lastMove = this.cellHistory[this.cellHistory.length - 1]; + + if ( + lastMove && + lastMove.newCoords.Y === y && + lastMove.newCoords.X === index + ) { + lastMove.merge = true; + lastMove.newCoords.X = index - 1; + lastMove.problem = true; + } else { + this.cellHistory.push({ + value: mergeValue, + oldCoords: { + Y: y, + X: index, + }, + newCoords: { + Y: y, + X: index - 1, + }, + merge: true, + }); + } + + board[y][index - 1] = mergeValue; + board[y][index] = 0; + index--; + } + index++; + } + } + } + + return board; + } + + moveRight(board) { + for (let y = 0; y < board.length; y++) { + let index = board.length - 1; + + for (let x = board.length - 1; x >= 0; x--) { + if (board[y][x] !== 0) { + const cellValue = board[y][x]; + + if (index !== x) { + this.cellHistory.push({ + value: cellValue, + oldCoords: { + Y: y, + X: x, + }, + newCoords: { + Y: y, + X: index, + }, + move: true, + }); + + board[y][index] = cellValue; + board[y][x] = 0; + } + + if ( + index < board.length - 1 && + board[y][index] === board[y][index + 1] + ) { + const mergeValue = board[y][index] * 2; + + const lastMove = this.cellHistory[this.cellHistory.length - 1]; + + if ( + lastMove && + lastMove.newCoords.Y === y && + lastMove.newCoords.X === index + ) { + lastMove.merge = true; + lastMove.newCoords.X = index + 1; + } else { + this.cellHistory.push({ + value: mergeValue, + oldCoords: { + Y: y, + X: index, + }, + newCoords: { + Y: y, + X: index + 1, + }, + merge: true, + }); + } + + board[y][index + 1] = mergeValue; + board[y][index] = 0; + index++; + } + index--; + } + } + } + + return board; + } + + moveUp(board) { + for (let x = 0; x < board.length; x++) { + let index = 0; + + for (let y = 0; y < board.length; y++) { + if (board[y][x] !== 0) { + const cellValue = board[y][x]; + + if (index !== y) { + this.cellHistory.push({ + value: cellValue, + oldCoords: { + Y: y, + X: x, + }, + newCoords: { + Y: index, + X: x, + }, + move: true, + }); + board[index][x] = cellValue; + board[y][x] = 0; + } + + if (index > 0 && board[index][x] === board[index - 1][x]) { + const mergeValue = board[index][x] * 2; + const lastMove = this.cellHistory[this.cellHistory.length - 1]; + + if ( + lastMove && + lastMove.newCoords.Y === index && + lastMove.newCoords.X === x + ) { + lastMove.merge = true; + lastMove.newCoords.Y = index - 1; + } else { + this.cellHistory.push({ + value: mergeValue, + oldCoords: { + Y: index, + X: x, + }, + newCoords: { + Y: index - 1, + X: x, + }, + merge: true, + }); + } + board[index - 1][x] = mergeValue; + board[index][x] = 0; + index--; + } + index++; + } + } + } + + return board; + } + + moveDown(board) { + for (let x = 0; x < board.length; x++) { + let index = board.length - 1; + + for (let y = board.length - 1; y >= 0; y--) { + if (board[y][x] !== 0) { + const cellValue = board[y][x]; + + if (index !== y) { + this.cellHistory.push({ + value: cellValue, + oldCoords: { + Y: y, + X: x, + }, + newCoords: { + Y: index, + X: x, + }, + move: true, + }); + board[index][x] = cellValue; + board[y][x] = 0; + } + + if ( + index < board.length - 1 && + board[index][x] === board[index + 1][x] + ) { + const mergeValue = board[index][x] * 2; + const lastMove = this.cellHistory[this.cellHistory.length - 1]; + + if ( + lastMove && + lastMove.newCoords.Y === index && + lastMove.newCoords.X === x + ) { + lastMove.merge = true; + lastMove.newCoords.Y = index + 1; + } else { + this.cellHistory.push({ + value: cellValue, + oldCoords: { + Y: y, + X: x, + }, + newCoords: { + Y: index + 1, + X: x, + }, + merge: true, + }); + } + board[index + 1][x] = mergeValue; + board[index][x] = 0; + index++; + } + index--; + } + } + } + + return board; + } + + // #endregion + + getScore() { + let score = 0; + + for (const el of this.board) { + score += el.reduce((acc, curVal) => acc + curVal, 0); + } + + return score; + } + + setStatus(stat) { + switch (stat) { + case 'playing': + this.status = 'playing'; + this.setMessage(); + break; + case 'idle': + this.status = 'idle'; + this.setMessage('start'); + break; + case 'win': + this.status = 'win'; + this.setMessage('win'); + break; + case 'lose': + this.status = 'lose'; + this.setMessage('lose'); + break; + default: + } + } + + getState() { + return this.board; + } + + getStatus() { + return this.status; + } + + start() { + this.startButton.textContent = 'Restart'; + this.startButton.className = 'button restart'; + this.setStatus('playing'); + this.board = JSON.parse(JSON.stringify(this.initialState)); + this.addRandomCell(); + this.addRandomCell(); + this.displayGame(); + } + + setMessage(message) { + const win = document.querySelector('.message-win'); + const lose = document.querySelector('.message-lose'); + const start = document.querySelector('.message-start'); + + lose.className = 'message message-lose hidden'; + start.className = 'message message-start hidden'; + win.className = 'message message-win hidden'; + + switch (message) { + case 'win': + win.className = 'message message-win'; + break; + case 'lose': + lose.className = 'message message-lose'; + break; + case 'start': + start.className = 'message message-start'; + break; + default: + } + } + + restart() { + this.clearBoard(); + this.cellHistory = []; + this.setStatus('idle'); + this.startButton.textContent = 'Start'; + this.startButton.className = 'button start'; + this.scoreboard.textContent = 0; + } + + getEmptyCell() { + const emptyCell = []; + + for (let y = 0; y < this.board.length; y++) { + for (let x = 0; x < this.board.length; x++) { + if (!this.board[y][x]) { + emptyCell.push({ x: x, y: y }); + } + } + } + + return emptyCell; + } + + addRandomCell() { + const emptyCell = this.getEmptyCell(); + + if (emptyCell.length > 0) { + const randomIndex = Math.floor(Math.random() * emptyCell.length); + const randomEmptyCell = emptyCell[randomIndex]; + + this.board[randomEmptyCell.y][randomEmptyCell.x] = + Math.random() < 0.9 ? 2 : 4; + + this.cellHistory.push({ + value: this.board[randomEmptyCell.y][randomEmptyCell.x], + newCoords: { + X: randomEmptyCell.x, + Y: randomEmptyCell.y, + }, + newCell: true, + }); + } + } + + displayGame() { + this.displayAnimateBoard(); + this.scoreboard.textContent = this.getScore(); + } + + displayAnimateBoard() { + for (const cellMove of this.cellHistory) { + const perentElement = document.querySelector('.move-zone'); + + if (cellMove.newCell) { + const newBlock = document.createElement('div'); + + perentElement.appendChild(newBlock); + newBlock.textContent = cellMove.value; + newBlock.className = `moving-block field-cell--${cellMove.value}`; + newBlock.id = `cell${cellMove.newCoords.Y}${cellMove.newCoords.X}`; + + newBlock.style.setProperty('--y', cellMove.newCoords.Y); + newBlock.style.setProperty('--x', cellMove.newCoords.X); + + const keyFrames = [ + { transform: 'scale(1)' }, + { transform: 'scale(1.56)' }, + { transform: 'scale(1)' }, + ]; + + const newspaperTiming = { + duration: 400, + iterations: 1, + easing: 'ease-in-out', + }; + + newBlock.animate(keyFrames, newspaperTiming); + } else { + if (this.getStatus() === 'playing') { + const movingBlock = document.getElementById( + `cell${cellMove.oldCoords.Y}${cellMove.oldCoords.X}`, + ); + + const nextBlockPosition = `cell${cellMove.newCoords.Y}${cellMove.newCoords.X}`; + + if (document.querySelector('#' + nextBlockPosition)) { + document.querySelector('#' + nextBlockPosition).remove(); + } + movingBlock.id = nextBlockPosition; + + movingBlock.className = `moving-block field-cell--${this.board[cellMove.newCoords.Y][cellMove.newCoords.X]}`; + + movingBlock.textContent = + this.board[cellMove.newCoords.Y][cellMove.newCoords.X]; + + movingBlock.style.setProperty('--y', cellMove.newCoords.Y); + movingBlock.style.setProperty('--x', cellMove.newCoords.X); + } + } + } + } + + nonAnimateUpdateBoard() { + this.clearBoard(); + + for (let y = 0; y < this.board.length; y++) { + for (let x = 0; x < this.board.length; x++) { + if (this.board[y][x] !== 0) { + const cell = document.querySelector(`#cell${y}${x}`); + + cell.textContent = this.board[y][x]; + cell.className = `field-cell field-cell--${this.board[y][x]}`; + } + } + } + } + + clearBoard() { + const childrenToRemove = document.querySelectorAll('.moving-block'); + + childrenToRemove.forEach((child) => { + child.remove(); + }); + } } module.exports = Game; diff --git a/src/scripts/main.js b/src/scripts/main.js index dc7f045a3..a1f009785 100644 --- a/src/scripts/main.js +++ b/src/scripts/main.js @@ -1,7 +1,37 @@ 'use strict'; -// Uncomment the next lines to use your game instance in the browser -// const Game = require('../modules/Game.class'); -// const game = new Game(); +const Game = require('../modules/Game.class'); -// Write your code here +const game = new Game(); + +const startButton = document.querySelector('.button'); +const animationButton = document.querySelector('h1'); + +startButton.onclick = () => { + if (startButton.textContent === 'Start') { + game.start(); + } else { + game.restart(); + } +}; + +animationButton.onclick = () => { + game.animation(); +}; + +addEventListener('keydown', (events) => { + switch (events.key) { + case 'ArrowUp': + game.move('up'); + break; + case 'ArrowDown': + game.move('down'); + break; + case 'ArrowRight': + game.move('right'); + break; + case 'ArrowLeft': + game.move('left'); + break; + } +}); diff --git a/src/styles/main.scss b/src/styles/main.scss index c43f37dcf..9e75bf8db 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -1,3 +1,5 @@ +@import 'variables'; + body { margin: 0; display: flex; @@ -8,70 +10,75 @@ body { font-family: sans-serif; font-size: 24px; font-weight: 900; + user-select: none; } .field-cell { - background: #d6cdc4; - width: 75px; - height: 75px; + --cell-background: #d6cdc4; + --cell-color: #776e65; + + background: var(--cell-background); + width: $cell-size; + height: $cell-size; border-radius: 5px; - color: #776e65; + color: var(--cell-color); box-sizing: border-box; text-align: center; vertical-align: center; user-select: none; + transition: opacity 0.3s ease; &--2 { - background: #eee4da; + --cell-background: #eee4da; } &--4 { - background: #ede0c8; + --cell-background: #ede0c8; } &--8 { - background: #f2b179; - color: #f9f6f2; + --cell-background: #f2b179; + --cell-color: #f9f6f2; } &--16 { - background: #f59563; - color: #f9f6f2; + --cell-background: #f59563; + --cell-color: #f9f6f2; } &--32 { - background: #f67c5f; - color: #f9f6f2; + --cell-background: #f67c5f; + --cell-color: #f9f6f2; } &--64 { - background: #f65e3b; - color: #f9f6f2; + --cell-background: #f65e3b; + --cell-color: #f9f6f2; } &--128 { - background: #edcf72; - color: #f9f6f2; + --cell-background: #edcf72; + --cell-color: #f9f6f2; } &--256 { - background: #edcc61; - color: #f9f6f2; + --cell-background: #edcc61; + --cell-color: #f9f6f2; } &--512 { - background: #edc850; - color: #f9f6f2; + --cell-background: #edc850; + --cell-color: #f9f6f2; } &--1024 { - background: #edc53f; - color: #f9f6f2; + --cell-background: #edc53f; + --cell-color: #f9f6f2; } &--2048 { - background: #edc22e; - color: #f9f6f2; + --cell-background: #edc22e; + --cell-color: #f9f6f2; } } @@ -93,8 +100,8 @@ body { h1 { background: #edc22e; color: #f9f6f2; - width: 75px; - height: 75px; + width: $cell-size; + height: $cell-size; font-size: 24px; border-radius: 5px; display: flex; @@ -110,8 +117,8 @@ h1 { align-items: center; justify-content: center; background: #d6cdc4; - width: 75px; - height: 75px; + width: $cell-size; + height: $cell-size; border-radius: 5px; color: #776e65; box-sizing: border-box; @@ -131,10 +138,14 @@ h1 { font-family: sans-serif; font-weight: 700; font-size: 16px; - width: 75px; - height: 75px; + width: $cell-size; + height: $cell-size; transition: 0.25s ease background; + + &:focus-visible { + outline: none; + } } .start { @@ -185,3 +196,30 @@ h1 { width: 100%; height: 150px; } + +.move-zone { + position: relative; +} + +.moving-block { + --y: 1; + --x: 1; + + display: flex; + align-items: center; + justify-content: center; + position: absolute; + border-radius: 5px; + vertical-align: center; + user-select: none; + text-align: center; + background: var(--cell-background); + color: var(--cell-color); + width: $cell-size; + height: $cell-size; + transition: 300ms; + box-sizing: border-box; + z-index: 1; + top: calc(var(--y) * 85px + 10px); + left: calc(var(--x) * 85px + 10px); +} diff --git a/src/styles/variables.scss b/src/styles/variables.scss new file mode 100644 index 000000000..3d590c55f --- /dev/null +++ b/src/styles/variables.scss @@ -0,0 +1 @@ +$cell-size: 75px;