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..bfa42d2fb 100644 --- a/README.md +++ b/README.md @@ -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://nex1994.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..5520ffa6a 100644 --- a/src/index.html +++ b/src/index.html @@ -9,7 +9,7 @@ 2048 @@ -21,38 +21,38 @@

2048

Score: 0

- + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + +
@@ -65,6 +65,6 @@

2048

- + diff --git a/src/modules/Game.class.js b/src/modules/Game.class.js index 65cd219c9..d82f380be 100644 --- a/src/modules/Game.class.js +++ b/src/modules/Game.class.js @@ -1,68 +1,165 @@ '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); + constructor( + cells = [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ], + ) { + this.cells = cells; + this.restart(); } - 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 + slideAndMerge(row) { + const filteredRow = row.filter((val) => val !== 0); + const newRow = []; + + for (let i = 0; i < filteredRow.length; i++) { + if (filteredRow[i] === filteredRow[i + 1]) { + newRow.push(filteredRow[i] * 2); + this.score += filteredRow[i] * 2; + i++; + } else { + newRow.push(filteredRow[i]); + } + } + + while (newRow.length < 4) { + newRow.push(0); + } + + return newRow; + } + + move(direction) { + if (this.status !== 'playing') { + return; + } + + const rotated = (board) => { + return board[0].map((_, colIndex) => board.map((row) => row[colIndex])); + }; + + let moved = false; + + if (direction === 'up' || direction === 'down') { + this.board = rotated(this.board); + } + + for (let i = 0; i < 4; i++) { + const row = + direction === 'right' || direction === 'down' + ? [...this.board[i]].reverse() + : [...this.board[i]]; + + const newRow = this.slideAndMerge(row); + + if (direction === 'right' || direction === 'down') { + newRow.reverse(); + } + + if (this.board[i].toString() !== newRow.toString()) { + this.board[i] = newRow; + moved = true; + } + } + + if (direction === 'up' || direction === 'down') { + this.board = rotated(this.board); + } + + if (moved) { + this.addRandomTitle(); + } + this.checkGameOver(); + } + + moveLeft() { + this.move('left'); + } + moveRight() { + this.move('right'); + } + moveUp() { + this.move('up'); + } + moveDown() { + this.move('down'); + } + + checkGameOver() { + if (this.board.some((row) => row.includes(2048))) { + this.status = 'win'; + } else if (!this.canMove()) { + this.status = 'lose'; + } + } + + canMove() { + for (let i = 0; i < 4; i++) { + for (let j = 0; j < 4; j++) { + if (this.board[i][j] === 0) { + return true; + } + + if (j < 3 && this.board[i][j] === this.board[i][j + 1]) { + return true; + } + + if (i < 3 && this.board[i][j] === this.board[i + 1][j]) { + return true; + } + } + } + + return false; + } + + addRandomTitle() { + const emptyCells = []; + + this.board.forEach((row, rowIndex) => { + row.forEach((value, colIndex) => { + if (value === 0) { + emptyCells.push({ rowIndex, colIndex }); + } + }); + }); + + if (emptyCells.length > 0) { + const randomIndex = Math.floor(Math.random() * emptyCells.length); + const { rowIndex, colIndex } = emptyCells[randomIndex]; + + this.board[rowIndex][colIndex] = Math.random() < 0.9 ? 2 : 4; + } + } + + getScore() { + return this.score; + } + + getState() { + return this.board.map((row) => [...row]); + } + + getStatus() { + return this.status; + } + + start() { + this.status = 'playing'; + this.addRandomTitle(); + this.addRandomTitle(); + } + + restart() { + this.board = this.cells.map((row) => [...row]); + this.score = 0; + this.status = 'idle'; + } } module.exports = Game; diff --git a/src/scripts/main.js b/src/scripts/main.js index dc7f045a3..f7cd7fe7a 100644 --- a/src/scripts/main.js +++ b/src/scripts/main.js @@ -1,7 +1,106 @@ '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-start'); +const scoreElement = document.querySelector('.game-score'); +const messageStart = document.querySelector('.message-start'); +const messageWin = document.querySelector('.message-win'); +const messageLose = document.querySelector('.message-lose'); + +function updateBoard() { + const state = game.getState(); + + state.forEach((row, rowIndex) => { + row.forEach((value, colIndex) => { + const cell = document.querySelector( + `[data-position="${rowIndex}-${colIndex}"]`, + ); + + if (!cell) { + return; + } + + cell.textContent = value !== 0 ? value : ''; + updateCellClass(cell, value); + }); + }); +} + +function updateCellClass(cell, value) { + cell.className = 'field-cell'; + + if (value !== 0) { + cell.classList.add(`field-cell--${value}`); + } +} + +function updateScore() { + scoreElement.textContent = game.getScore(); +} + +function updateMessages() { + const statusGame = game.getStatus(); + + messageStart.classList.add('hidden'); + messageLose.classList.add('hidden'); + messageWin.classList.add('hidden'); + + switch (statusGame) { + case 'win': + messageWin.classList.remove('hidden'); + break; + case 'lose': + messageLose.classList.remove('hidden'); + break; + case 'idle': + messageStart.classList.remove('hidden'); + break; + default: + break; + } +} + +startButton.addEventListener('click', () => { + if (startButton.classList.contains('start')) { + game.start(); + updateBoard(); + updateMessages(); + startButton.classList.remove('start'); + startButton.classList.add('restart'); + startButton.textContent = 'Restart'; + } else { + game.restart(); + updateBoard(); + updateMessages(); + updateScore(); + startButton.classList.remove('restart'); + startButton.classList.add('start'); + startButton.textContent = 'Start'; + } +}); + +window.addEventListener('keydown', (ev) => { + switch (ev.key) { + case 'ArrowLeft': + game.moveLeft(); + break; + case 'ArrowRight': + game.moveRight(); + break; + case 'ArrowUp': + game.moveUp(); + break; + case 'ArrowDown': + game.moveDown(); + break; + default: + return; + } + + updateBoard(); + updateScore(); + updateMessages(); +}); diff --git a/src/styles/styles.css b/src/styles/styles.css new file mode 100644 index 000000000..66eb260c8 --- /dev/null +++ b/src/styles/styles.css @@ -0,0 +1,178 @@ +/* stylelint-disable rule-empty-line-before */ +body { + margin: 0; + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: #fbf8ef; + font-family: sans-serif; + font-size: 24px; + font-weight: 900; +} + +.field-cell { + background: #d6cdc4; + width: 75px; + height: 75px; + border-radius: 5px; + color: #776e65; + box-sizing: border-box; + text-align: center; + vertical-align: center; + user-select: none; +} + +.field-cell--2 { + background: #eee4da; +} + +.field-cell--4 { + background: #ede0c8; +} +.field-cell--8 { + background: #f2b179; + color: #f9f6f2; +} +.field-cell--16 { + background: #f59563; + color: #f9f6f2; +} +.field-cell--32 { + background: #f67c5f; + color: #f9f6f2; +} +.field-cell--64 { + background: #f65e3b; + color: #f9f6f2; +} +.field-cell--128 { + background: #edcf72; + color: #f9f6f2; +} +.field-cell--256 { + background: #edcc61; + color: #f9f6f2; +} +.field-cell--512 { + background: #edc850; + color: #f9f6f2; +} +.field-cell--1024 { + background: #edc53f; + color: #f9f6f2; +} +.field-cell--2048 { + background: #edc22e; + color: #f9f6f2; +} + +.game-field { + background: #bbada0; + border-spacing: 10px; + border-radius: 5px; +} + +.game-header { + display: flex; + width: 100%; + justify-content: space-between; + margin-bottom: 24px; + padding: 10px; + box-sizing: border-box; +} + +h1 { + background: #edc22e; + color: #f9f6f2; + width: 75px; + height: 75px; + font-size: 24px; + border-radius: 5px; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + margin: 0; +} + +.info { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: #d6cdc4; + width: 75px; + height: 75px; + border-radius: 5px; + color: #776e65; + box-sizing: border-box; + font-size: 16px; + margin: 0 8px 0 0; +} + +.controls { + display: flex; +} + +.button { + border: none; + border-radius: 5px; + cursor: pointer; + color: #f9f6f2; + font-family: sans-serif; + font-weight: 700; + font-size: 16px; + width: 75px; + height: 75px; + transition: 0.25s ease background; +} + +.start { + background: #1dae28; + font-size: 20px; +} +.start:hover { + background: #179921; +} + +.restart { + background: #f1b2b2; +} +.restart:hover { + background: #f87474; +} + +.message { + box-sizing: border-box; + width: 100%; + background: #d6cdc4; + color: #776e65; + padding: 10px; + text-align: center; + border-radius: 5px; + font-size: 20px; +} + +.hidden { + display: none; +} + +.container { + display: flex; + flex-direction: column; + align-items: center; + width: 350px; +} + +.message-win { + background: #edc22e; + color: #f9f6f2; +} + +.message-container { + width: 100%; + height: 150px; +} + +/*# sourceMappingURL=styles.css.map */ diff --git a/src/styles/styles.css.map b/src/styles/styles.css.map new file mode 100644 index 000000000..1b57aaa98 --- /dev/null +++ b/src/styles/styles.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["main.scss"],"names":[],"mappings":"AAAA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;;AAGF;EACE;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;;AAIJ;EACE;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAEA;;;AAGF;EACE;EACA;;AAEA;EACE;;;AAIJ;EACE;;AAEA;EACE;;;AAIJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA","file":"styles.css"} \ No newline at end of file