├── 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 | }
--------------------------------------------------------------------------------