diff --git a/src/scripts/Button.js b/src/scripts/Button.js new file mode 100644 index 000000000..dcf7ffedc --- /dev/null +++ b/src/scripts/Button.js @@ -0,0 +1,28 @@ +'use strict'; + +class Button { + constructor(element, startFunc, restartFunc) { + const START_TEXT = 'Start'; + const START_CLASS = 'start'; + const RESTART_TEXT = 'Restart'; + const RESTART_CLASS = 'restart'; + + this.element = element; + + this.element.addEventListener('click', () => { + if (this.element.classList.contains(START_CLASS)) { + startFunc(); + this.element.classList.remove(START_CLASS); + this.element.classList.add(RESTART_CLASS); + this.element.textContent = RESTART_TEXT; + } else if (this.element.classList.contains(RESTART_CLASS)) { + restartFunc(); + this.element.classList.remove(RESTART_CLASS); + this.element.classList.add(START_CLASS); + this.element.textContent = START_TEXT; + } + }); + } +} + +module.exports = Button; diff --git a/src/scripts/Cell.js b/src/scripts/Cell.js new file mode 100644 index 000000000..15cd43855 --- /dev/null +++ b/src/scripts/Cell.js @@ -0,0 +1,28 @@ +'use strict'; + +class Cell { + constructor(element) { + this.isEmpty = true; + this.value = 0; + this.element = element; + } + + setValue(value) { + if (!this.isEmpty) { + this.element.classList.remove('field-cell--' + this.value); + } + this.isEmpty = false; + this.element.classList.add('field-cell--' + value); + this.value = value; + this.element.textContent = value; + } + + clear() { + this.isEmpty = true; + this.element.classList.remove('field-cell--' + this.value); + this.value = 0; + this.element.textContent = ''; + } +} + +module.exports = Cell; diff --git a/src/scripts/Controls.js b/src/scripts/Controls.js new file mode 100644 index 000000000..6694d88e0 --- /dev/null +++ b/src/scripts/Controls.js @@ -0,0 +1,48 @@ +'use strict'; + +class Controls { + constructor(onLeft, onRight, onUp, onDown, onAction) { + this.enabled = false; + this.onLeft = onLeft; + this.onRight = onRight; + this.onUp = onUp; + this.onDown = onDown; + this.onAction = onAction; + + this.KEYS = { + LEFT: 'ArrowLeft', + RIGHT: 'ArrowRight', + UP: 'ArrowUp', + DOWN: 'ArrowDown', + }; + + document.addEventListener('keydown', (keyboardEvent) => { + if (!this.enabled) { + return; + } + + switch (keyboardEvent.key) { + case this.KEYS.LEFT: + this.onLeft(); + break; + case this.KEYS.RIGHT: + this.onRight(); + break; + case this.KEYS.UP: + this.onUp(); + break; + case this.KEYS.DOWN: + this.onDown(); + break; + default: + break; + } + + if (Object.values(this.KEYS).includes(keyboardEvent.key)) { + this.onAction(); + } + }); + } +} + +module.exports = Controls; diff --git a/src/scripts/Field.js b/src/scripts/Field.js new file mode 100644 index 000000000..58aba4186 --- /dev/null +++ b/src/scripts/Field.js @@ -0,0 +1,184 @@ +'use strict'; + +const Cell = require('./Cell'); + +class Field { + constructor(element) { + this.cells = []; + + const rows = element.querySelectorAll('.field-row'); + + rows.forEach((row) => { + const cellElements = row.querySelectorAll('.field-cell'); + const rowCells = [...cellElements].map((cell) => new Cell(cell)); + + this.cells.push(rowCells); + }); + } + + getMaxValue() { + return this.cells.reduce((maxValue, row) => { + const rowMaxValue = row.reduce((rowMax, cell) => { + return Math.max(rowMax, cell.value); + }, 0); + + return Math.max(maxValue, rowMaxValue); + }, 0); + } + + hasAvailableMoves() { + let hasEmptyCell = false; + + this.cells.forEach((row, rowIndex) => { + row.forEach((cell, columnIndex) => { + const isEmpty = cell.isEmpty; + const isValueAboveSame = rowIndex > 0 + && this.cells[rowIndex - 1][columnIndex].value === cell.value; + const isValueToLeftSame = columnIndex > 0 + && this.cells[rowIndex][columnIndex - 1].value === cell.value; + + if (isEmpty || isValueAboveSame || isValueToLeftSame) { + hasEmptyCell = true; + } + }); + }); + + return hasEmptyCell; + } + + mergeCells(cells) { + let score = 0; + let currentIndex = 0; + + cells.forEach((cell, i) => { + if (!cell.isEmpty) { + cells[currentIndex].setValue(cell.value); + + if (currentIndex !== i) { + cell.clear(); + } + currentIndex++; + } + }); + + for (let i = 0; i < cells.length - 1; i++) { + if (!cells[i].isEmpty && cells[i].value === cells[i + 1].value) { + const mergedValue = cells[i].value * 2; + + cells[i].setValue(mergedValue); + cells[i + 1].clear(); + score += mergedValue; + i++; + } + } + + currentIndex = 0; + + cells.forEach((cell, i) => { + if (!cell.isEmpty) { + cells[currentIndex].setValue(cell.value); + + if (currentIndex !== i) { + cell.clear(); + } + currentIndex++; + } + }); + + return score; + } + + shiftLeft() { + let totalScore = 0; + + this.cells.forEach((row) => { + totalScore += this.mergeCells(row); + }); + + return totalScore; + } + + shiftRight() { + let totalScore = 0; + + this.cells.forEach((row) => { + const reversedRow = row.slice().reverse(); + + totalScore += this.mergeCells(reversedRow); + }); + + return totalScore; + } + + shiftUp() { + let totalScore = 0; + + const transposedCells = this.transposeCells(); + + transposedCells.forEach((row) => { + totalScore += this.mergeCells(row); + }); + + return totalScore; + } + + shiftDown() { + let totalScore = 0; + + const transposedCells = this.transposeCells(); + + transposedCells.forEach((row) => { + const reversedRow = row.slice().reverse(); + + totalScore += this.mergeCells(reversedRow); + }); + + return totalScore; + } + + transposeCells() { + const transposedCells = []; + + for (let column = 0; column < this.cells[0].length; column++) { + transposedCells.push(this.cells.map((row) => row[column])); + } + + return transposedCells; + } + + addTile(value) { + const emptyCells = []; + + this.cells.forEach((row, rowIndex) => { + row.forEach((cell, columnIndex) => { + if (cell.isEmpty) { + emptyCells.push({ + row: rowIndex, + column: columnIndex, + }); + } + }); + }); + + if (emptyCells.length === 0) { + return false; + } + + const randomIndex = Math.floor(Math.random() * emptyCells.length); + const randomCell = emptyCells[randomIndex]; + + this.cells[randomCell.row][randomCell.column].setValue(value); + + return true; + } + + reset() { + this.cells.forEach((row) => { + row.forEach((cell) => { + cell.clear(); + }); + }); + } +} + +module.exports = Field; diff --git a/src/scripts/Game.js b/src/scripts/Game.js new file mode 100644 index 000000000..42acdbb1e --- /dev/null +++ b/src/scripts/Game.js @@ -0,0 +1,98 @@ +'use strict'; + +const Field = require('./Field'); +const Button = require('./Button'); +const Message = require('./Message'); +const Score = require('./Score'); +const SwipeControls = require('./SwipeControls'); + +class Game { + constructor() { + this.START_VALUE = 2; + this.END_VALUE = 2048; + + this.MESSAGES = { + WIN: 'win', + LOSE: 'lose', + START: 'start', + }; + + const getRandomValue = (min, max) => { + return Math.floor(Math.random() * (max - min + 1)) + min; + }; + + this.score = new Score(document.querySelector('.game-score')); + this.message = new Message(document.querySelector('.message-container')); + this.field = new Field(document.querySelector('.game-field')); + + const onStart = () => { + this.field.addTile(this.START_VALUE * getRandomValue(1, 2)); + this.field.addTile(this.START_VALUE * getRandomValue(1, 2)); + this.message.setMessage(); + this.controls.enabled = true; + }; + + const onRestart = () => { + this.score.reset(); + this.message.setMessage(this.MESSAGES.START); + this.field.reset(); + this.controls.enabled = false; + }; + + const onLeft = () => { + const points = this.field.shiftLeft(); + + this.score.addPoints(points); + }; + + const onRight = () => { + const points = this.field.shiftRight(); + + this.score.addPoints(points); + }; + + const onUp = () => { + const points = this.field.shiftUp(); + + this.score.addPoints(points); + }; + + const onDown = () => { + const points = this.field.shiftDown(); + + this.score.addPoints(points); + }; + + const onAction = () => { + const maxValue = this.field.getMaxValue(); + + if (maxValue === this.END_VALUE) { + this.message.setMessage(this.MESSAGES.WIN); + this.controls.enabled = false; + + return; + } + + if (!this.field.hasAvailableMoves()) { + this.message.setMessage(this.MESSAGES.LOSE); + this.controls.enabled = false; + + return; + } + + const newValue = this.START_VALUE * getRandomValue(1, 2); + + this.field.addTile(newValue); + }; + + this.button = new Button( + document.querySelector('.controls .button'), + onStart, onRestart + ); + + this.controls = new SwipeControls(onLeft, onRight, onUp, onDown, onAction); + this.message.setMessage(this.MESSAGES.START); + } +} + +module.exports = Game; diff --git a/src/scripts/Message.js b/src/scripts/Message.js new file mode 100644 index 000000000..59869999d --- /dev/null +++ b/src/scripts/Message.js @@ -0,0 +1,21 @@ +'use strict'; + +class Message { + constructor(element) { + this.element = element; + } + + setMessage(messageType) { + const messages = this.element.querySelectorAll('.message'); + + messages.forEach((message) => { + if (message.classList.contains(`message-${messageType}`)) { + message.classList.remove('hidden'); + } else { + message.classList.add('hidden'); + } + }); + } +} + +module.exports = Message; diff --git a/src/scripts/Score.js b/src/scripts/Score.js new file mode 100644 index 000000000..1b30b8eab --- /dev/null +++ b/src/scripts/Score.js @@ -0,0 +1,20 @@ +'use strict'; + +class Score { + constructor(element) { + this.element = element; + this.value = 0; + } + + addPoints(value) { + this.value += value; + this.element.textContent = this.value; + } + + reset() { + this.value = 0; + this.element.textContent = this.value; + } +} + +module.exports = Score; diff --git a/src/scripts/SwipeControls.js b/src/scripts/SwipeControls.js new file mode 100644 index 000000000..055b39820 --- /dev/null +++ b/src/scripts/SwipeControls.js @@ -0,0 +1,47 @@ +'use strict'; + +const Controls = require('./Controls'); + +class SwipeControls extends Controls { + constructor(onLeft, onRight, onUp, onDown, onAction) { + super(onLeft, onRight, onUp, onDown, onAction); + + let touchStartX, touchStartY; + + document.addEventListener('touchstart', (e) => { + touchStartX = e.touches[0].clientX; + touchStartY = e.touches[0].clientY; + }); + + document.addEventListener('touchend', (e) => { + const touchEndX = e.changedTouches[0].clientX; + const touchEndY = e.changedTouches[0].clientY; + const deltaX = touchEndX - touchStartX; + const deltaY = touchEndY - touchStartY; + + if (Math.abs(deltaX) > Math.abs(deltaY)) { + // Minimum threshold for a valid horizontal swipe + if (Math.abs(deltaX) > 50) { + if (deltaX > 0) { + this.onRight(); + } else { + this.onLeft(); + } + this.onAction(); + } + } else { + // Minimum threshold for a valid vertical swipe + if (Math.abs(deltaY) > 50) { + if (deltaY > 0) { + this.onDown(); + } else { + this.onUp(); + } + this.onAction(); + } + } + }); + } +} + +module.exports = SwipeControls; diff --git a/src/scripts/main.js b/src/scripts/main.js index c6e3f8784..03a270db2 100644 --- a/src/scripts/main.js +++ b/src/scripts/main.js @@ -1,3 +1,9 @@ 'use strict'; -// write your code here +const Game = require('./Game'); + +function initializeGame() { + return new Game(); +} + +initializeGame();