I wrote a 2048 game in JavaScript with an object-oriented paradigm. The game board is presented with a two-dimensional array and each tile holds an integer.
Here is the implementation:
class Game {
SIZE = 4
constructor() {
this.board = Array.from({ length: this.SIZE * this.SIZE }, () => 0).reduce(
(arrays, curr) => {
const lastArray = arrays[arrays.length - 1]
if (lastArray.length < this.SIZE) lastArray.push(curr)
else arrays.push([curr])
return arrays
},
[[]]
)
this.isWin = false
this._init()
}
_init() {
const pickedTiles = this._randomlyPick(2)
for (const [row, col] of pickedTiles) {
this.board[row][col] = Game.generateTile()
}
}
static generateTile() {
if (Math.random() > 0.5) return 2
return 4
}
_getEmptyTiles() {
const emptyTiles = []
for (let row = 0; row < this.SIZE; row++) {
for (let col = 0; col < this.SIZE; col++) {
if (this.board[row][col] === 0) emptyTiles.push([col, row])
}
}
return emptyTiles
}
_randomlyPick(numOfItems) {
const emptyTiles = this._getEmptyTiles()
for (let i = 0; i < numOfItems; i++) {
const toSwap = i + Math.floor(Math.random() * (emptyTiles.length - i))
;[emptyTiles[i], emptyTiles[toSwap]] = [emptyTiles[toSwap], emptyTiles[i]]
}
return emptyTiles.slice(0, numOfItems)
}
spawn() {
// randomly spawn empty tiles with 2 or 4
const [emtpyTile] = this._randomlyPick(1)
this.board[emtpyTile[0]][emtpyTile[1]] = Game.generateTile()
}
play(dir) {
if (this.canPlay()) {
switch (dir) {
case Game.moveUp:
this._mergeUp()
break
case Game.moveRight:
this._mergeRight()
break
case Game.moveLeft:
this._mergeLeft()
break
case Game.moveDown:
this._mergeDown()
break
}
this.spawn()
return true
}
return false
}
checkIsWin() {
return this.isWin
}
static peek(array) {
return array[array.length - 1]
}
static zip(arrays) {
const result = []
for (let i = 0; i < arrays[0].length; ++i) {
result.push(arrays.map((array) => array[i]))
}
return result
}
_mergeRowRight(sparseRow) {
const row = sparseRow.filter((x) => x !== 0)
const result = []
while (row.length) {
let value = row.pop()
if (Game.peek(row) === value) value += row.pop()
result.unshift(value)
}
while (result.length < 4) result.unshift(0)
return result
}
_mergeRowLeft(row) {
return this._mergeRowRight([...row].reverse()).reverse()
}
_mergeUp() {
this.board = Game.zip(Game.zip(this.board).map(row => this._mergeRowLeft(row)))
}
_mergeDown() {
this.board = Game.zip(Game.zip(this.board).map(row => this._mergeRight(row)))
}
_mergeRight() {
this.board = this.board.map((row) => this._mergeRowRight(row))
}
_mergeLeft() {
this.board = this.board.map((row) => this._mergeRowLeft(row))
}
canPlay() {
const dirs = [
[0, 1],
[1, 0],
[-1, 0],
[0, -1],
]
const visited = new Set()
for (let row = 0; row < this.SIZE; row++) {
for (let col = 0; col < this.SIZE; col++) {
if (visited.has([row, col].toString())) continue
const tile = this.board[row][col]
if (tile === 2048) {
this.isWin = true
return false
}
if (tile === 0) return true
for (const [dx, dy] of dirs) {
if (this.board[row + dx]?.[col + dy] === tile) return true
}
visited.add([row, col].toString())
}
}
return false
}
}
Game.moveUp = Symbol('moveUp')
Game.moveDown = Symbol('moveUp')
Game.moveLeft = Symbol('moveUp')
Game.moveRight = Symbol('moveUp')
const game = new Game()
console.log(game.board);
game.play(Game.moveUp)
console.log(game.board);
game.play(Game.moveRight)
console.log(game.board);
Any feedback is welcomed. Specifically, I would like to know:
- Is it idiomatic in OO to use static methods to store util functions such as
zip
to flip the board along its diagonal? - Is there a better way to structure the class? I feel like I am not doing a great job at dividing some of the logic of different actions.
- Is there any specific design pattern that I can use to improve the class?
- Lastly, I am using
symbol
to present the direction of the move the user can make and attach them as the instance variable to the class. Again I am not sure if this is idiomatic in OO since I am kinda new to this paradigm.