├── Grid.js ├── Tile.js ├── index.html ├── script.js └── styles.css /Grid.js: -------------------------------------------------------------------------------- 1 | const GRID_SIZE = 4 2 | const CELL_SIZE = 20 3 | const CELL_GAP = 2 4 | 5 | export default class Grid { 6 | #cells 7 | 8 | constructor(gridElement) { 9 | gridElement.style.setProperty("--grid-size", GRID_SIZE) 10 | gridElement.style.setProperty("--cell-size", `${CELL_SIZE}vmin`) 11 | gridElement.style.setProperty("--cell-gap", `${CELL_GAP}vmin`) 12 | this.#cells = createCellElements(gridElement).map((cellElement, index) => { 13 | return new Cell( 14 | cellElement, 15 | index % GRID_SIZE, 16 | Math.floor(index / GRID_SIZE) 17 | ) 18 | }) 19 | } 20 | 21 | get cells() { 22 | return this.#cells 23 | } 24 | 25 | get cellsByRow() { 26 | return this.#cells.reduce((cellGrid, cell) => { 27 | cellGrid[cell.y] = cellGrid[cell.y] || [] 28 | cellGrid[cell.y][cell.x] = cell 29 | return cellGrid 30 | }, []) 31 | } 32 | 33 | get cellsByColumn() { 34 | return this.#cells.reduce((cellGrid, cell) => { 35 | cellGrid[cell.x] = cellGrid[cell.x] || [] 36 | cellGrid[cell.x][cell.y] = cell 37 | return cellGrid 38 | }, []) 39 | } 40 | 41 | get #emptyCells() { 42 | return this.#cells.filter(cell => cell.tile == null) 43 | } 44 | 45 | randomEmptyCell() { 46 | const randomIndex = Math.floor(Math.random() * this.#emptyCells.length) 47 | return this.#emptyCells[randomIndex] 48 | } 49 | } 50 | 51 | class Cell { 52 | #cellElement 53 | #x 54 | #y 55 | #tile 56 | #mergeTile 57 | 58 | constructor(cellElement, x, y) { 59 | this.#cellElement = cellElement 60 | this.#x = x 61 | this.#y = y 62 | } 63 | 64 | get x() { 65 | return this.#x 66 | } 67 | 68 | get y() { 69 | return this.#y 70 | } 71 | 72 | get tile() { 73 | return this.#tile 74 | } 75 | 76 | set tile(value) { 77 | this.#tile = value 78 | if (value == null) return 79 | this.#tile.x = this.#x 80 | this.#tile.y = this.#y 81 | } 82 | 83 | get mergeTile() { 84 | return this.#mergeTile 85 | } 86 | 87 | set mergeTile(value) { 88 | this.#mergeTile = value 89 | if (value == null) return 90 | this.#mergeTile.x = this.#x 91 | this.#mergeTile.y = this.#y 92 | } 93 | 94 | canAccept(tile) { 95 | return ( 96 | this.tile == null || 97 | (this.mergeTile == null && this.tile.value === tile.value) 98 | ) 99 | } 100 | 101 | mergeTiles() { 102 | if (this.tile == null || this.mergeTile == null) return 103 | this.tile.value = this.tile.value + this.mergeTile.value 104 | this.mergeTile.remove() 105 | this.mergeTile = null 106 | } 107 | } 108 | 109 | function createCellElements(gridElement) { 110 | const cells = [] 111 | for (let i = 0; i < GRID_SIZE * GRID_SIZE; i++) { 112 | const cell = document.createElement("div") 113 | cell.classList.add("cell") 114 | cells.push(cell) 115 | gridElement.append(cell) 116 | } 117 | return cells 118 | } 119 | -------------------------------------------------------------------------------- /Tile.js: -------------------------------------------------------------------------------- 1 | export default class Tile { 2 | #tileElement 3 | #x 4 | #y 5 | #value 6 | 7 | constructor(tileContainer, value = Math.random() > 0.5 ? 2 : 4) { 8 | this.#tileElement = document.createElement("div") 9 | this.#tileElement.classList.add("tile") 10 | tileContainer.append(this.#tileElement) 11 | this.value = value 12 | } 13 | 14 | get value() { 15 | return this.#value 16 | } 17 | 18 | set value(v) { 19 | this.#value = v 20 | this.#tileElement.textContent = v 21 | const power = Math.log2(v) 22 | const backgroundLightness = 100 - power * 9 23 | this.#tileElement.style.setProperty( 24 | "--background-lightness", 25 | `${backgroundLightness}%` 26 | ) 27 | this.#tileElement.style.setProperty( 28 | "--text-lightness", 29 | `${backgroundLightness <= 50 ? 90 : 10}%` 30 | ) 31 | } 32 | 33 | set x(value) { 34 | this.#x = value 35 | this.#tileElement.style.setProperty("--x", value) 36 | } 37 | 38 | set y(value) { 39 | this.#y = value 40 | this.#tileElement.style.setProperty("--y", value) 41 | } 42 | 43 | remove() { 44 | this.#tileElement.remove() 45 | } 46 | 47 | waitForTransition(animation = false) { 48 | return new Promise(resolve => { 49 | this.#tileElement.addEventListener( 50 | animation ? "animationend" : "transitionend", 51 | resolve, 52 | { 53 | once: true, 54 | } 55 | ) 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | 11 | 12 |
13 | 14 | -------------------------------------------------------------------------------- /script.js: -------------------------------------------------------------------------------- 1 | import Grid from "./Grid.js" 2 | import Tile from "./Tile.js" 3 | 4 | const gameBoard = document.getElementById("game-board") 5 | 6 | const grid = new Grid(gameBoard) 7 | grid.randomEmptyCell().tile = new Tile(gameBoard) 8 | grid.randomEmptyCell().tile = new Tile(gameBoard) 9 | setupInput() 10 | 11 | function setupInput() { 12 | window.addEventListener("keydown", handleInput, { once: true }) 13 | } 14 | 15 | async function handleInput(e) { 16 | switch (e.key) { 17 | case "ArrowUp": 18 | if (!canMoveUp()) { 19 | setupInput() 20 | return 21 | } 22 | await moveUp() 23 | break 24 | case "ArrowDown": 25 | if (!canMoveDown()) { 26 | setupInput() 27 | return 28 | } 29 | await moveDown() 30 | break 31 | case "ArrowLeft": 32 | if (!canMoveLeft()) { 33 | setupInput() 34 | return 35 | } 36 | await moveLeft() 37 | break 38 | case "ArrowRight": 39 | if (!canMoveRight()) { 40 | setupInput() 41 | return 42 | } 43 | await moveRight() 44 | break 45 | default: 46 | setupInput() 47 | return 48 | } 49 | 50 | grid.cells.forEach(cell => cell.mergeTiles()) 51 | 52 | const newTile = new Tile(gameBoard) 53 | grid.randomEmptyCell().tile = newTile 54 | 55 | if (!canMoveUp() && !canMoveDown() && !canMoveLeft() && !canMoveRight()) { 56 | newTile.waitForTransition(true).then(() => { 57 | alert("You lose") 58 | }) 59 | return 60 | } 61 | 62 | setupInput() 63 | } 64 | 65 | function moveUp() { 66 | return slideTiles(grid.cellsByColumn) 67 | } 68 | 69 | function moveDown() { 70 | return slideTiles(grid.cellsByColumn.map(column => [...column].reverse())) 71 | } 72 | 73 | function moveLeft() { 74 | return slideTiles(grid.cellsByRow) 75 | } 76 | 77 | function moveRight() { 78 | return slideTiles(grid.cellsByRow.map(row => [...row].reverse())) 79 | } 80 | 81 | function slideTiles(cells) { 82 | return Promise.all( 83 | cells.flatMap(group => { 84 | const promises = [] 85 | for (let i = 1; i < group.length; i++) { 86 | const cell = group[i] 87 | if (cell.tile == null) continue 88 | let lastValidCell 89 | for (let j = i - 1; j >= 0; j--) { 90 | const moveToCell = group[j] 91 | if (!moveToCell.canAccept(cell.tile)) break 92 | lastValidCell = moveToCell 93 | } 94 | 95 | if (lastValidCell != null) { 96 | promises.push(cell.tile.waitForTransition()) 97 | if (lastValidCell.tile != null) { 98 | lastValidCell.mergeTile = cell.tile 99 | } else { 100 | lastValidCell.tile = cell.tile 101 | } 102 | cell.tile = null 103 | } 104 | } 105 | return promises 106 | }) 107 | ) 108 | } 109 | 110 | function canMoveUp() { 111 | return canMove(grid.cellsByColumn) 112 | } 113 | 114 | function canMoveDown() { 115 | return canMove(grid.cellsByColumn.map(column => [...column].reverse())) 116 | } 117 | 118 | function canMoveLeft() { 119 | return canMove(grid.cellsByRow) 120 | } 121 | 122 | function canMoveRight() { 123 | return canMove(grid.cellsByRow.map(row => [...row].reverse())) 124 | } 125 | 126 | function canMove(cells) { 127 | return cells.some(group => { 128 | return group.some((cell, index) => { 129 | if (index === 0) return false 130 | if (cell.tile == null) return false 131 | const moveToCell = group[index - 1] 132 | return moveToCell.canAccept(cell.tile) 133 | }) 134 | }) 135 | } 136 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | *, *::before, *::after { 2 | box-sizing: border-box; 3 | font-family: Arial; 4 | } 5 | 6 | body { 7 | background-color: #333; 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | height: 100vh; 12 | margin: 0; 13 | font-size: 7.5vmin; 14 | } 15 | 16 | #game-board { 17 | display: grid; 18 | grid-template-columns: repeat(var(--grid-size), var(--cell-size)); 19 | grid-template-rows: repeat(var(--grid-size), var(--cell-size)); 20 | background-color: #CCC; 21 | gap: var(--cell-gap); 22 | border-radius: 1vmin; 23 | padding: var(--cell-gap); 24 | position: relative; 25 | } 26 | 27 | .cell { 28 | background-color: #AAA; 29 | border-radius: 1vmin; 30 | } 31 | 32 | .tile { 33 | position: absolute; 34 | display: flex; 35 | justify-content: center; 36 | align-items: center; 37 | width: var(--cell-size); 38 | height: var(--cell-size); 39 | background-color: red; 40 | border-radius: 1vmin; 41 | top: calc(var(--y) * (var(--cell-size) + var(--cell-gap)) + var(--cell-gap)); 42 | left: calc(var(--x) * (var(--cell-size) + var(--cell-gap)) + var(--cell-gap)); 43 | font-weight: bold; 44 | background-color: hsl(200, 50%, var(--background-lightness)); 45 | color: hsl(200, 25%, var(--text-lightness)); 46 | animation: show 200ms ease-in-out; 47 | transition: 100ms ease-in-out; 48 | } 49 | 50 | @keyframes show { 51 | 0% { 52 | opacity: .5; 53 | transform: scale(0); 54 | } 55 | } --------------------------------------------------------------------------------