diff --git a/README.md b/README.md index 5aab92544..d88555df8 100644 --- a/README.md +++ b/README.md @@ -7,51 +7,56 @@ In this task, you need to implement the 2048 game like in [this reference](https Don't play for too long! We need you to write the code! Okay, what do we have? -1) HTML and CSS are already written. You can use it, or implement your own design if you want. -2) Base `Game` class structure is already written too. Extend it with your own methods. Obligatory methods (used in tests): - - constructor with `initialState` parameter (value is optional, defaults to the empty board) - - `getState()` - - `getScore()` - - `getStatus()` - - `moveLeft()` - - `moveRight()` - - `moveUp()` - - `moveDown()` - - `start()` - - `restart()` - -3) Reference. + +1. HTML and CSS are already written. You can use it, or implement your own design if you want. +2. Base `Game` class structure is already written too. Extend it with your own methods. Obligatory methods (used in tests): + +- constructor with `initialState` parameter (value is optional, defaults to the empty board) +- `getState()` +- `getScore()` +- `getStatus()` +- `moveLeft()` +- `moveRight()` +- `moveUp()` +- `moveDown()` +- `start()` +- `restart()` + +3. Reference. That's it! Okay, okay. Also, we have some rules: -1) The game field is 4 x 4 -2) Each cell can be empty or contain one of the numbers: 2, 4, 8 ... 2^n -3) The player can move cells with keyboard arrows -4) All the numbers should be moved in the selected direction until all empty cells are filled in + +1. The game field is 4 x 4 +2. Each cell can be empty or contain one of the numbers: 2, 4, 8 ... 2^n +3. The player can move cells with keyboard arrows +4. All the numbers should be moved in the selected direction until all empty cells are filled in - 2 equal cells should be merged into a doubled number - The merged cell can’t be merged twice during one move -5) The move is possible if at least one cell is changed after the move -6) After move 2 or 4 appears in a random empty cell. 4 probability is 10% -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. -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: - - game logic written in `src/modules/Game.class.js` module that exports `Game` class - - game UI written in `src/index.html` with `main.js` script that need to use `Game` class instance +5. The move is possible if at least one cell is changed after the move +6. After move 2 or 4 appears in a random empty cell. 4 probability is 10% +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. +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: + +- game logic written in `src/modules/Game.class.js` module that exports `Game` class +- game UI written in `src/index.html` with `main.js` script that need to use `Game` class instance Hints: + - You have class `field-cell--%cell_value%`, for styling cell in the game. - Use `hidden` class for hiding elements on page. - Use `start`, `restart` classes for the main button for different styles. - Use `field-cell--%cell_value%` class like additional class, don't replace the main class. - Use `keydown` event and `event.key` property to handle arrow buttons presses - ```js - document.addEventListener('keydown', event => console.log(event.key)); - ``` + ```js + document.addEventListener('keydown', (event) => console.log(event.key)); + ``` - Adding animation to the game is optional. It is a bit tricky, but you can try it if you want. Probably, you will need to extend the Game class with additional methods and create a separate board storage with Tile entities to operate their corresponding DOM elements' positions. You can change the HTML/CSS layout if you need it. @@ -61,9 +66,8 @@ 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://maksym2493.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; - - Run `npm run test:only -- -l` to run fast test with additional info in console ignoring linter. - + - Run `npm run test` command to test your code; + - Run `npm run test:only -- -n` to run fast test ignoring linter; + - Run `npm run test:only -- -l` to run fast test with additional info in console ignoring linter. diff --git a/src/index.html b/src/index.html index aff3d1a98..cdfcfd518 100644 --- a/src/index.html +++ b/src/index.html @@ -65,6 +65,9 @@

2048

- + diff --git a/src/modules/Game.class.js b/src/modules/Game.class.js index 65cd219c9..8d388115f 100644 --- a/src/modules/Game.class.js +++ b/src/modules/Game.class.js @@ -6,6 +6,17 @@ * Feel free to add more props and methods if needed. */ class Game { + size = 4; + probability = 10; + + status = { + started: false, + lost: false, + won: false, + }; + + score = 0; + /** * Creates a new game instance. * @@ -20,25 +31,232 @@ class Game { * If passed, the board will be initialized with the provided * initial state. */ - constructor(initialState) { - // eslint-disable-next-line no-console - console.log(initialState); + constructor(initialState = this.createField()) { + this.state = initialState; + this.initialState = this.copyState(initialState); + } + + /** + * Starts the game. + */ + start() { + if (this.isPlaying) { + return; + } + + this.status.started = true; + + for (let _ = 0; _ < 2; _++) { + this.addRandomCell(); + } + } + + /** + * Resets the game. + */ + restart() { + this.score = 0; + this.state = this.copyState(this.initialState); + + this.status.won = false; + this.status.lost = false; + this.status.started = false; + } + + // #region moves logic + moveRow(callback) { + if (!this.isPlaying) { + return; + } + + const prevState = this.getState(); + + for (let i = 0; i < this.size; i++) { + this.state[i] = this.stackRow(this.state[i], callback); + } + + if (this.isChanged(prevState)) { + this.addRandomCell(); + this.status.lost = this.isLost; + } + } + + moveColumn(callback) { + if (!this.isPlaying) { + return; + } + + const prevState = this.getState(); + + for (let j = 0; j < this.size; j++) { + const column = this.stackRow( + this.state.map((row) => row[j]), + callback, + ); + + column.forEach((cell, i) => (this.state[i][j] = cell)); + } + + if (this.isChanged(prevState)) { + this.addRandomCell(); + this.status.lost = this.isLost; + } + } + + stackRow(initialRow, fillCallback) { + const row = initialRow.filter(Boolean); + + for (let j = 1; j < row.length; j++) { + if (row[j - 1] === row[j]) { + const score = row[j - 1] * 2; + + if (score === 2048) { + this.status.won = true; + } + + row[j - 1] = score; + this.score += score; + + row.splice(j, 1); + } + } + + while (row.length !== this.size) { + fillCallback(row); + } + + return row; + } + // #endregion + + // #region moves directions + moveLeft() { + this.moveRow((row) => row.push(0)); + } + + moveRight() { + this.moveRow((row) => row.unshift(0)); + } + + moveUp() { + this.moveColumn((column) => column.push(0)); } - moveLeft() {} - moveRight() {} - moveUp() {} - moveDown() {} + moveDown() { + this.moveColumn((column) => column.unshift(0)); + } + // #endregion + + // #region utils + createField() { + return new Array(this.size) + .fill(null) + .map(() => new Array(this.size).fill(0)); + } + + copyState(state) { + return state.map((row) => [...row]); + } + + isChanged(prevState) { + for (let i = 0; i < this.size; i++) { + for (let j = 0; j < this.size; j++) { + if (prevState[i][j] !== this.state[i][j]) { + return true; + } + } + } + + return false; + } + + get isPlaying() { + switch (true) { + case !this.status.started: + case this.status.won: + case this.status.lost: + return false; + + default: + return true; + } + } + get isLost() { + for (let i = 0; i < this.size; i++) { + const column = this.state.map((row) => row[i]); + + if (this.hasMoves(this.state[i]) || this.hasMoves(column)) { + return false; + } + } + + return true; + } + + hasMoves(row) { + if (row.includes(0)) { + return true; + } + + for (let i = 1; i < row.length; i++) { + if (row[i - 1] === row[i]) { + return true; + } + } + + return false; + } + // #endregion + + // #region add random cell + addRandomCell() { + const i = this.getRandomRow(); + const j = this.getRandomColumn(i); + + this.state[i][j] = !getRandomNumber(0, this.probability) ? 4 : 2; + } + + getRandomRow() { + const rows = []; + + for (let i = 0; i < this.size; i++) { + if (this.state[i].includes(0)) { + rows.push(i); + } + } + + return rows[getRandomNumber(0, rows.length)]; + } + + getRandomColumn(i) { + const columns = []; + const row = this.state[i]; + + for (let j = 0; j < row.length; j++) { + if (!row[j]) { + columns.push(j); + } + } + + return columns[getRandomNumber(0, columns.length)]; + } + // #endregion + + // #region getters /** * @returns {number} */ - getScore() {} + getScore() { + return this.score; + } /** * @returns {number[][]} */ - getState() {} + getState() { + return this.copyState(this.state); + } /** * Returns the current game status. @@ -50,19 +268,25 @@ class Game { * `win` - the game is won; * `lose` - the game is lost */ - getStatus() {} + getStatus() { + switch (true) { + case !this.status.started: + return 'idle'; - /** - * Starts the game. - */ - start() {} + case this.status.won: + return 'win'; - /** - * Resets the game. - */ - restart() {} + case this.status.lost: + return 'lose'; + } + + return 'playing'; + } + // #endregion +} - // Add your own methods here +function getRandomNumber(min, max) { + return Math.floor(Math.random() * (max - min)) + min; } module.exports = Game; diff --git a/src/scripts/main.js b/src/scripts/main.js index dc7f045a3..f87d465b2 100644 --- a/src/scripts/main.js +++ b/src/scripts/main.js @@ -1,7 +1,119 @@ -'use strict'; +import { onTouchStart, onTouchEnd } from './swipeHandler'; +import { gameField, updateField, isChanged } from './updateField'; -// 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'); +const game = new Game(); -// Write your code here +const gameScore = document.querySelector('.game-score'); +const startButton = document.querySelector('.button.start'); + +const winMessage = document.querySelector('.message.message-win'); +const loseMessage = document.querySelector('.message.message-lose'); +const startMessage = document.querySelector('.message.message-start'); + +function touchMovesListener(e) { + const result = onTouchEnd(e); + + if (result) { + movesListener(result); + } +} + +function movesListener(e) { + const prevState = game.getState(); + + switch (e.key) { + case 'ArrowUp': + game.moveUp(); + break; + + case 'ArrowRight': + game.moveRight(); + break; + + case 'ArrowDown': + game.moveDown(); + break; + + case 'ArrowLeft': + game.moveLeft(); + break; + + default: + return; + } + + if ( + startButton.classList.contains('start') && + isChanged(prevState, game.getState()) + ) { + startButton.disabled = false; + startButton.textContent = 'Restart'; + + startButton.classList.add('restart'); + startButton.classList.remove('start'); + } + + gameScore.textContent = game.getScore(); + updateField(game.getState(), e.key.replace('Arrow', '').toLowerCase()); + + const gameStatus = game.getStatus(); + + if (gameStatus === 'playing') { + return; + } + + document.removeEventListener('keydown', movesListener); + + gameField.removeEventListener('touchstart', onTouchStart); + gameField.removeEventListener('touchend', touchMovesListener); + + if (gameStatus === 'win') { + winMessage.classList.remove('hidden'); + + return; + } + + loseMessage.classList.remove('hidden'); +} + +startButton.addEventListener('click', () => { + if (startButton.classList.contains('start')) { + game.start(); + updateField(game.getState()); + + startMessage.classList.add('hidden'); + + startButton.disabled = true; + document.addEventListener('keydown', movesListener); + + gameField.addEventListener('touchstart', onTouchStart, false); + gameField.addEventListener('touchend', touchMovesListener, false); + + return; + } + + game.restart(); + + game.start(); + updateField(game.getState()); + + gameScore.textContent = '0'; + + if (!winMessage.classList.contains('hidden')) { + winMessage.classList.add('hidden'); + } + + if (!loseMessage.classList.contains('hidden')) { + loseMessage.classList.add('hidden'); + } + + gameField.removeEventListener('touchstart', onTouchStart); + gameField.removeEventListener('touchend', touchMovesListener); + + gameField.addEventListener('touchstart', onTouchStart, false); + gameField.addEventListener('touchend', touchMovesListener, false); + + document.removeEventListener('keydown', movesListener); + document.addEventListener('keydown', movesListener); +}); diff --git a/src/scripts/swipeHandler.js b/src/scripts/swipeHandler.js new file mode 100644 index 000000000..8b85a54f5 --- /dev/null +++ b/src/scripts/swipeHandler.js @@ -0,0 +1,47 @@ +let startX, startY, endX, endY; +const minDistance = 15; + +export function onTouchStart(e) { + e.preventDefault(); + + const touch = e.touches[0]; + + startX = touch.pageX; + startY = touch.pageY; +} + +export function onTouchEnd(e) { + const touch = e.changedTouches[0]; + + endX = touch.pageX; + endY = touch.pageY; + + const deltaX = endX - startX; + const deltaY = endY - startY; + + if (Math.abs(deltaX) > Math.abs(deltaY)) { + if (deltaX > minDistance) { + e.key = 'ArrowRight'; + + return e; + } + + if (deltaX < -minDistance) { + e.key = 'ArrowLeft'; + + return e; + } + } + + if (deltaY > minDistance) { + e.key = 'ArrowDown'; + + return e; + } + + if (deltaY < -minDistance) { + e.key = 'ArrowUp'; + + return e; + } +} diff --git a/src/scripts/updateField.js b/src/scripts/updateField.js new file mode 100644 index 000000000..d08160d19 --- /dev/null +++ b/src/scripts/updateField.js @@ -0,0 +1,279 @@ +export const gameField = document.querySelector('.game-field tbody'); + +const DIRECTIONS = { + left: (prevState, state) => moveLeft(prevState, state), + right: (prevState, state) => moveRight(prevState, state), + up: (prevState, state) => moveUp(prevState, state), + down: (prevState, state) => moveDown(prevState, state), +}; + +let lastChange = null; +let lastTimeout = null; + +const ANIMATION_DURATION = 250; +// const ANIMATION_DURATION = 5000; + +export function updateField(state, direction) { + if (lastChange) { + if (!isChanged(lastChange, state)) { + return; + } + + clearTimeout(lastTimeout); + + clearField(); + fillField(lastChange); + + lastChange = null; + lastTimeout = null; + } + + const changes = [createCopy(clearField())]; + + if (!isChanged(changes[0], state)) { + fillField(state); + + return; + } + + if (direction) { + while (true) { + changes.push( + createCopy(DIRECTIONS[direction](createCopy(changes.at(-1)), state)), + ); + + if (!isChanged(changes.at(-1), changes.at(-2))) { + changes.pop(); + changes.shift(); + + changes[changes.length - 1] = state; + + break; + } + } + + lastChange = state; + addChanges(changes, ANIMATION_DURATION / changes.length); + + return; + } + + fillField(state); +} + +export function isChanged(prevState, state) { + for (let i = 0; i < prevState.length; i++) { + for (let j = 0; j < prevState[i].length; j++) { + if (prevState[i][j] !== state[i][j]) { + return true; + } + } + } + + return false; +} + +function createCopy(state) { + return state.map((row) => [...row]); +} + +function addChanges(changes, duration) { + clearField(); + fillField(changes.shift()); + + if (changes.length) { + lastTimeout = setTimeout(addChanges, duration, changes, duration); + + return; + } + + lastChange = null; + lastTimeout = null; +} + +function fillField(state) { + for (let i = 0; i < state.length; i++) { + const row = state[i]; + + for (let j = 0; j < row.length; j++) { + if (state[i][j]) { + gameField.rows[i].cells[j].textContent = state[i][j]; + gameField.rows[i].cells[j].classList.add(`field-cell--${state[i][j]}`); + } + } + } +} + +function moveLeft(prevState, state) { + for (let i = 0; i < prevState.length; i++) { + const row = prevState[i]; + const end = row.length - 1; + + let count = 0; + let next = false; + + for (let j = 0; j < end && !next; j++) { + if (j === count && row[j] === state[i][j]) { + count++; + continue; + } + + if (!row[j]) { + next = true; + + row.splice(j, 1); + row.push(0); + + continue; + } + + if (row[j + 1] && row[j] === row[j + 1]) { + next = true; + + row[j] = +row[j] * 2; + + row.splice(j + 1, 1); + row.push(0); + } + } + } + + return prevState; +} + +function moveRight(prevState, state) { + for (let i = 0; i < prevState.length; i++) { + const row = prevState[i]; + + let next = false; + let count = row.length - 1; + + for (let j = row.length - 1; j > 0 && !next; j--) { + if (j === count && row[j] === state[i][j]) { + count--; + continue; + } + + if (!row[j]) { + next = true; + + row.splice(j, 1); + row.unshift(0); + + continue; + } + + if (row[j - 1] && row[j] === row[j - 1]) { + next = true; + + row[j] = +row[j] * 2; + + row.splice(j - 1, 1); + row.unshift(0); + } + } + } + + return prevState; +} + +function moveUp(prevState, state) { + for (let j = 0; j < prevState.length; j++) { + const end = prevState.length - 1; + + let count = 0; + let next = false; + + for (let i = 0; i < end && !next; i++) { + if (i === count && prevState[i][j] === state[i][j]) { + count++; + continue; + } + + if (!prevState[i][j]) { + next = true; + removeUp(prevState, i, j); + + continue; + } + + if (prevState[i + 1][j] && prevState[i][j] === prevState[i + 1][j]) { + prevState[i][j] *= 2; + + next = true; + removeUp(prevState, i + 1, j); + } + } + } + + return prevState; +} + +function moveDown(prevState, state) { + for (let j = 0; j < prevState.length; j++) { + let next = false; + let count = prevState.length - 1; + + for (let i = prevState.length - 1; i > 0 && !next; i--) { + if (count === i && prevState[i][j] === state[i][j]) { + count--; + continue; + } + + if (!prevState[i][j]) { + next = true; + removeDown(prevState, i, j); + + continue; + } + + if (prevState[i - 1][j] && prevState[i][j] === prevState[i - 1][j]) { + prevState[i][j] = +prevState[i][j] * 2; + + next = true; + removeDown(prevState, i - 1, j); + } + } + } + + return prevState; +} + +function removeUp(prevState, init, j) { + const end = prevState.length - 1; + + for (let i = init; i < end; i++) { + prevState[i][j] = prevState[i + 1][j]; + } + + prevState[end][j] = 0; +} + +function removeDown(prevState, init, j) { + for (let i = init; i > 0; i--) { + prevState[i][j] = prevState[i - 1][j]; + } + + prevState[0][j] = 0; +} + +function clearField() { + const state = []; + + for (let i = 0; i < gameField.rows.length; i++) { + state.push([]); + + const row = gameField.rows[i].cells; + + for (let j = 0; j < row.length; j++) { + state[i].push(+row[j].textContent); + + if (row[j].textContent) { + row[j].classList.remove(`field-cell--${row[j].textContent}`); + row[j].textContent = ''; + } + } + } + + return state; +} diff --git a/src/styles/main.scss b/src/styles/main.scss index c43f37dcf..3495062be 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -8,6 +8,8 @@ body { font-family: sans-serif; font-size: 24px; font-weight: 900; + + touch-action: none; } .field-cell {