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..4d6237d7f 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://Viktoria-Moroz.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..16b73d391 100644 --- a/src/index.html +++ b/src/index.html @@ -1,70 +1,29 @@ - + - - + + 2048 + + href="https://fonts.googleapis.com/css2?family=Chewy&display=swap" + rel="stylesheet"> + -
-
-

2048

-
-

- Score: - 0 -

- -
+
+

2048

+
+ Score: 0
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
- - -

- Press "Start" to begin game. Good luck! -

+
+ +
- + +
+ + diff --git a/src/modules/Game.class.js b/src/modules/Game.class.js deleted file mode 100644 index 65cd219c9..000000000 --- a/src/modules/Game.class.js +++ /dev/null @@ -1,68 +0,0 @@ -'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 -} - -module.exports = Game; diff --git a/src/scripts/cell.js b/src/scripts/cell.js new file mode 100644 index 000000000..7c6d57521 --- /dev/null +++ b/src/scripts/cell.js @@ -0,0 +1,48 @@ +export class Cell { + constructor(gridElement, x, y) { + const cell = document.createElement('div'); + + cell.classList.add('cell'); + gridElement.append(cell); + this.x = x; + this.y = y; + } + + linkTile(tile) { + tile.setXY(this.x, this.y); + this.linkedTile = tile; + } + + unlinkTile() { + this.linkedTile = null; + } + + isEmpty() { + return !this.linkedTile; + } + + linkTileForMerge(tile) { + tile.setXY(this.x, this.y); + this.linkedTileForMerge = tile; + } + + unlinkTileForMerge() { + this.linkedTileForMerge = null; + } + + hasTileForMerge() { + return !!this.linkedTileForMerge; + } + + canAccept(newTile) { + return this.isEmpty() || (!this.hasTileForMerge() + && this.linkedTile.value === newTile.value); + } + + mergeTiles() { + this.linkedTile.setValue(this.linkedTile.value + + this.linkedTileForMerge.value); + this.linkedTileForMerge.removeFromDOM(); + this.unlinkTileForMerge(); + } +} diff --git a/src/scripts/grid.js b/src/scripts/grid.js new file mode 100644 index 000000000..ac72c7deb --- /dev/null +++ b/src/scripts/grid.js @@ -0,0 +1,51 @@ +import { Cell } from '../scripts/cell'; + +const GRID_SIZE = 4; +const CELLS_COUNT = GRID_SIZE * GRID_SIZE; + +export class Grid { + constructor(gridElement) { + this.cells = []; + + for (let i = 0; i < CELLS_COUNT; i++) { + this.cells.push( + new Cell(gridElement, i % GRID_SIZE, Math.floor(i / GRID_SIZE)), + ); + } + + this.cellsGroupedByColumn = this.groupCellsByColumn(); + + this.cellsGroupedByReversedColumn = this.cellsGroupedByColumn.map( + column => [...column].reverse()); + + this.cellsGroupedByRow = this.groupCellsByRow(); + + this.cellsGroupedByReversedRow = this.cellsGroupedByRow.map( + row => [...row].reverse()); + } + + getRandomEmptyCell() { + const emptyCells = this.cells.filter(cell => cell.isEmpty()); + const randomIndex = Math.floor(Math.random() * emptyCells.length); + + return emptyCells[randomIndex]; + } + + groupCellsByColumn() { + return this.cells.reduce((groupedCells, cell) => { + groupedCells[cell.x] = groupedCells[cell.x] || []; + groupedCells[cell.x][cell.y] = cell; + + return groupedCells; + }, []); + } + + groupCellsByRow() { + return this.cells.reduce((groupedCells, cell) => { + groupedCells[cell.y] = groupedCells[cell.y] || []; + groupedCells[cell.y][cell.x] = cell; + + return groupedCells; + }, []); + } +} diff --git a/src/scripts/main.js b/src/scripts/main.js index dc7f045a3..226de34e7 100644 --- a/src/scripts/main.js +++ b/src/scripts/main.js @@ -1,7 +1,208 @@ -'use strict'; +import { Grid } from '../scripts/grid'; +import { Tile } from '../scripts/tile'; -// Uncomment the next lines to use your game instance in the browser -// const Game = require('../modules/Game.class'); -// const game = new Game(); +const gameBoard = document.querySelector('.game-board'); -// Write your code here +const scoreValueElement = document.getElementById('score-value'); +const startButton = document.querySelector('.button'); +const restartButton = document.querySelector('.restart-button'); + +let score = 0; +let gameStarted = false; +let grid; + +startButton.addEventListener('click', startGame); +restartButton.addEventListener('click', restartGame); + +function startGame() { + gameStarted = true; + startButton.style.display = 'none'; + restartButton.style.display = 'inline-block'; + + initializeGrid(); +} + +function restartGame() { + gameStarted = false; + startButton.style.display = 'inline-block'; + restartButton.style.display = 'none'; + score = 0; + updateScoreUI(); + + gameBoard.innerHTML = ''; + grid = null; +} + +function initializeGrid() { + gameBoard.innerHTML = ''; + grid = new Grid(gameBoard); + + grid.getRandomEmptyCell().linkTile(new Tile(gameBoard)); + grid.getRandomEmptyCell().linkTile(new Tile(gameBoard)); + + score = 0; + updateScoreUI(); + setupInputOnce(); +} + +function updateScoreUI() { + scoreValueElement.textContent = score; +} + +function setupInputOnce() { + window.addEventListener('keydown', handleInput, { once: true }); +} + +async function handleInput(e) { + if (!gameStarted) { + setupInputOnce(); + + return; + } + + switch (e.key) { + case 'ArrowUp': + if (!canMoveUp()) { + setupInputOnce(); + + return; + } + await moveUp(); + break; + case 'ArrowDown': + if (!canMoveDown()) { + setupInputOnce(); + + return; + } + await moveDown(); + break; + case 'ArrowLeft': + if (!canMoveleft()) { + setupInputOnce(); + + return; + } + await moveLeft(); + break; + case 'ArrowRight': + if (!canMoveRight()) { + setupInputOnce(); + + return; + } + await moveRight(); + break; + default: + setupInputOnce(); + + return; + } + + const newTile = new Tile(gameBoard); + + grid.getRandomEmptyCell().linkTile(newTile); + + if (!canMoveUp() && !canMoveDown() && !canMoveRight() && !canMoveleft()) { + await newTile.waitForAnimationEnd(); + alert('Game Over! Try again.'); + } + + setupInputOnce(); +} + +async function moveUp() { + await slideTiles(grid.cellsGroupedByColumn); +} + +async function moveDown() { + await slideTiles(grid.cellsGroupedByReversedColumn); +} + +async function moveLeft() { + await slideTiles(grid.cellsGroupedByRow); +} + +async function moveRight() { + await slideTiles(grid.cellsGroupedByReversedRow); +} + +async function slideTiles(groupedCells) { + const promises = []; + + groupedCells.forEach(group => slideTilesInGroup(group, promises)); + await Promise.all(promises); + + grid.cells.forEach(cell => { + if (cell.hasTileForMerge()) { + score += cell.linkedTile.value; + updateScoreUI(); + cell.mergeTiles(); + } + }); +} + +function slideTilesInGroup(group, promises) { + for (let i = 1; i < group.length; i++) { + if (group[i].isEmpty()) { + continue; + } + + const cellWithTile = group[i]; + let targetCell; + let j = i - 1; + + while (j >= 0 && group[j].canAccept(cellWithTile.linkedTile)) { + targetCell = group[j]; + j--; + } + + if (!targetCell) { + continue; + } + promises.push(cellWithTile.linkedTile.waitForTransitionEnd); + + if (targetCell.isEmpty()) { + targetCell.linkTile(cellWithTile.linkedTile); + } else { + targetCell.linkTileForMerge(cellWithTile.linkedTile); + } + cellWithTile.unlinkTile(); + } +} + +function canMoveUp() { + return canMove(grid.cellsGroupedByColumn); +} + +function canMoveDown() { + return canMove(grid.cellsGroupedByReversedColumn); +} + +function canMoveleft() { + return canMove(grid.cellsGroupedByRow); +} + +function canMoveRight() { + return canMove(grid.cellsGroupedByReversedRow); +} + +function canMove(groupedCells) { + return groupedCells.some(group => canMoveInGroup(group)); +} + +function canMoveInGroup(group) { + return group.some((cell, index) => { + if (index === 0) { + return false; + } + + if (cell.isEmpty()) { + return false; + } + + const targetCell = group[index - 1]; + + return targetCell.canAccept(cell.linkedTile); + }); +} diff --git a/src/scripts/tile.js b/src/scripts/tile.js new file mode 100644 index 000000000..dd8bf73c2 --- /dev/null +++ b/src/scripts/tile.js @@ -0,0 +1,45 @@ +export class Tile { + constructor(gridElement) { + this.tileElement = document.createElement('div'); + this.tileElement.classList.add('tile'); + this.setValue(Math.random() > 0.5 ? 2 : 4); + gridElement.append(this.tileElement); + } + + setXY(x, y) { + this.x = x; + this.y = y; + this.tileElement.style.setProperty('--x', x); + this.tileElement.style.setProperty('--y', y); + } + + setValue(value) { + this.value = value; + this.tileElement.textContent = value; + + const bgLightness = 100 - Math.log2(value) * 9; + + this.tileElement.style.setProperty('--bg-lightness', `${bgLightness}%`); + + this.tileElement.style.setProperty('--text-lightness', + `${bgLightness < 50 ? 90 : 10}%`); + } + + removeFromDOM() { + this.tileElement.remove(); + } + + waitForTransitionEnd() { + return new Promise(resolve => { + this.tileElement.addEventListener( + 'transitionend', resolve, { once: true }); + }); + } + + waitForAnimationEnd() { + return new Promise(resolve => { + this.tileElement.addEventListener( + 'animationend', resolve, { once: true }); + }); + } +} diff --git a/src/styles/__variables.scss b/src/styles/__variables.scss new file mode 100644 index 000000000..f16bd3a1c --- /dev/null +++ b/src/styles/__variables.scss @@ -0,0 +1,4 @@ +$cell-size: 15vmin; +$cell-gap: 1vmin; +$y: 1; +$x: 2; diff --git a/src/styles/main.scss b/src/styles/main.scss index c43f37dcf..995e3e784 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -1,187 +1,112 @@ -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; -} +@import './_variables'; -.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; - - &--2 { - background: #eee4da; - } - - &--4 { - background: #ede0c8; - } - - &--8 { - background: #f2b179; - color: #f9f6f2; - } - - &--16 { - background: #f59563; - color: #f9f6f2; - } - - &--32 { - background: #f67c5f; - color: #f9f6f2; - } - - &--64 { - background: #f65e3b; - color: #f9f6f2; - } - - &--128 { - background: #edcf72; - color: #f9f6f2; - } - - &--256 { - background: #edcc61; - color: #f9f6f2; - } - - &--512 { - background: #edc850; - color: #f9f6f2; - } - - &--1024 { - background: #edc53f; - color: #f9f6f2; - } - - &--2048 { - background: #edc22e; - color: #f9f6f2; - } + margin: 0; + padding: 0; } -.game-field { - background: #bbada0; - border-spacing: 10px; - border-radius: 5px; +body { + background: linear-gradient(135deg, #ff9e2c, #ff2c63); + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + font-family: Chewy, cursive; + min-height: 100vh; + color: #fff; + overflow-y: auto; + padding: 20px; } -.game-header { - display: flex; +.header-container { width: 100%; - justify-content: space-between; - margin-bottom: 24px; - padding: 10px; - box-sizing: border-box; + max-width: 600px; + text-align: center; + margin-top: 2rem; } -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; +.game-title { + font-size: 4rem; + margin-bottom: 1rem; + text-shadow: 2px 2px #000; } -.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; +.score-container { + font-size: 2rem; + margin-bottom: 1rem; + text-shadow: 1px 1px #000; } -.controls { - display: flex; +.buttons-container { + margin-bottom: 1.5rem; } -.button { - border: none; - border-radius: 5px; +.buttons-container .button { + font-size: 1.5rem; + padding: 0.5rem 1.5rem; + margin: 0 0.5rem; cursor: pointer; - color: #f9f6f2; - font-family: sans-serif; - font-weight: 700; - font-size: 16px; - width: 75px; - height: 75px; - - transition: 0.25s ease background; + background: #4e4e50; + border: none; + border-radius: 10px; + color: #fff; + transition: background-color 0.3s, transform 0.1s; + box-shadow: 0 0 10px rgba(0,0,0,0.3); } -.start { - background: #1dae28; - font-size: 20px; - - &:hover { - background: #179921; - } +.buttons-container .button:hover { + background: #6e6e70; } -.restart { - background: #f1b2b2; - - &:hover { - background: #f87474; - } +.buttons-container .button:active { + transform: scale(0.95); } -.message { - box-sizing: border-box; - width: 100%; - background: #d6cdc4; - color: #776e65; - padding: 10px; - text-align: center; - border-radius: 5px; - font-size: 20px; +.game-board { + position: relative; + display: grid; + grid-template-columns: repeat(4, $cell-size); + grid-template-rows: repeat(4, $cell-size); + gap: $cell-gap; + border-radius: 1vmin; + box-shadow: 0 0 20px rgba(0,0,0,0.5); } -.hidden { - display: none; +.cell { + background-color: #444; + border-radius: 1vmin; } -.container { +.tile { + position: absolute; + top: calc(var(--y) * (#{$cell-size} + #{$cell-gap})); + left: calc(var(--x) * (#{$cell-size} + #{$cell-gap})); + display: flex; - flex-direction: column; + justify-content: center; align-items: center; - width: 350px; + + width: $cell-size; + height: $cell-size; + border-radius: 1vmin; + + font-size: 7.5vmin; + font-weight: bold; + background-color: hsl(25deg, 60%, var(--bg-lightness)); + color: hsl(20deg, 25%, var(--text-lightness)); + + transition: 100ms; + animation: show 200ms; } -.message-win { - background: #edc22e; - color: #f9f6f2; +@keyframes show { + 0% { + opacity: 0.5s; + transform: scale(0); + } } -.message-container { - width: 100%; - height: 150px; +.restart-button { + display: none; }