Skip to content

Instantly share code, notes, and snippets.

@marshallshen
Created October 26, 2017 14:08
Show Gist options
  • Save marshallshen/b22ae088272268e65fbdea971047b9fa to your computer and use it in GitHub Desktop.
Save marshallshen/b22ae088272268e65fbdea971047b9fa to your computer and use it in GitHub Desktop.

Build 2048 with Vue.js

2048 is a popular game for a single player. The game has a 4 by 4 grid for tiles. Each tile can either be empty or has a card with value. As a player, you can move left, right, up and down. As cards get merged, the numbers on the cards change as well. The goal is to get number on the card as high as possible. Our final project looks like this:

our_final_product

Let's see how we can build this game using Vue.js!

Get started with project scaffold

First, we need to set up a project scaffold to build a Vue project. We are going to use plain javascript so that we can understand what is going on. Let's start by cloing the plain javascript Vue scaffold. The scaffold provides several directories for us:

  • assets. This is where we put all third party javascript library, stylesheets, and images.
  • components. PLACEHOLDER
  • mixins. PLACEHOLDER
  • store. PLACEHOLDER
  • main.js. PLACEHOLDER

Step 1. set up game board

In this step we will:

  1. create game component with board
  2. create shadow board
  3. create seeding function

First we have a game component that is in charge of a board. A Board has many tiles, and each tile has a value that defaults to 0. We can start by registering the component game:

src/omponents/game.js

((() => {
  const html = `
    <div class="game">
    </div>
  `

  Vue.component("game", {
    template: html,
    data () {
      return {
        board: [],
      }
    },
  })
}))()

On the Vue component game, we have a data property board that holds an array of tiles, each tile holds a default value of 0. Next we add a shadow board as the base layer of the board game inside our html.

src/omponents/game.js

((() => {
  const html = `
    <div class="game">
      <div class="game-container">
       <div class="board shadow-board">
          <div v-for="n in board.length" :key="n" class="tile shadow-tile"></div>
        </div>
      </div>
    </div>
  `
  ...
}))()

Now when you open index.html, it should look like this:

step_1_shodow_board

During the game, we need to find an empty tile and set the value of that tile to 2, so we write a seedTwo() function and put it under methods of Game() component.

  Vue.component("game", {
    ...
    methods: {
      seedTwo() {
        const self = this

        let getRandomItem = function() {
          let row = self.board[Math.floor(Math.random()*self.board.length)]
          return row[Math.floor(Math.random()*row.length)]
        }

        let initialRandomItem = getRandomItem()

        while (initialRandomItem.value != 0) {
          initialRandomItem = getRandomItem()
        }

        initialRandomItem.value = 2
      }
    }
  })

  ...

Step 2. define object models

In this step we will:

  1. create Tile() component
  2. modify Tile() CSS class based on wether the tile is empty or not
  3. create GameMenu() component

A board can hold 16 tiles (4 by 4), and we add another component tile under src/components/tile.js. The tile components take a tile object as a required props, which needs to include {value: X} as part of the object.

src/omponents/tile.js

((() => {
  const html = `
    <div class="tile">
      {{ value }}
    </div>
  `

  Vue.component("tile", {
    template: html,
    props: {
      tile: {
        type: Object,
        required: true
      },
    },

    computed: {
      value() {
        return this.tile.value
      },
    }
  })
}))()

Notice that in our final product, the style of each tile change as we change the a tile from being empty (value 0) to non-empty (value not 0). Inside the component, we construct computed properties based on the value, and later on we use the computed property to toggle the style by class binding:

src/omponents/tile.js

((() => {
  const html = `
    <div class="tile">
      {{ displayingValue }}
    </div>
  `

  Vue.component("tile", {
    template: html,
    props: {
      tile: {
        type: Object,
        required: true
      },
    },

    computed: {
      value() {
        return this.tile.value
      },

      displayingValue() {
        if (this.value > 0) {
          return this.value
        }

        return null
      },

      emptyTile() {
        return this.displayingValue === null
      },
    }
  })
}))()

Given we have a tile component, we plug the tile back in the game component:

src/omponents/game.js

((() => {
  const html = `
    <div class="game">
      <div class="game-container">
        <tile v-for="tile in board" :tile="tile" :key="tile.id"></tile>
        <div class="board shadow-board">
          <div v-for="n in board.length" :key="n" class="tile shadow-tile"></div>
        </div>
      </div>
    </div>
  `
  ...
}))()

Now that we have the computed property emptyTile, we can plug into our html and toggle class:

src/omponents/tile.js

((() => {
  const html = `
    <div class="tile" v-bind:class="{'tile-empty': emptyTile}">
      {{ displayingValue }}
    </div>
  `

  Vue.component("tile", {
    ...
  })
}))()

Next we can define a game-menu to show current score, show the best score, and start a new game. Let's start with a plain scaffold:

src/omponents/game-menu.js

((() => {
  const html = `
    <div class="game-menu">
      <div class="row">
        <div class="title">2048</div>
        <div class="scores space-right">
          <div class="score">
            <div class="score-title">SCORE</div>
            <div class="score-value">0000</div>
          </div>
          <div class="score">
            <div class="score-title">BEST</div>
            <div class="score-value">0000</div>
          </div>
        </div>
      </div>
      <a class="button space-right">New Game</a>
    </div>
  `

  Vue.component("game-menu", {
    template: html,
  })
}))()

Let's wire up the "New Game" button! Because game-menu component is nested inside game component. We can consider game-menu as the child component for the parent component game. Vue has a nice paradigm for child-parent communication where a child component can emit events up to parent component and let the parent handle the event. This paradigm is called "data down, event up". You can read more about Vue events handling here.

So let's register the events on game-menu.js

src/omponents/game-menu.js

((() => {
  const html = `
    <div class="game-menu">
      ...
      <a class="button space-right" @click="newGame()">New Game</a>
    </div>
  `

  Vue.component("game-menu", {
    template: html,

    methods: {
      newGame() {
        this.$emit("new-game")
      }
    }
  })
}))()

this.$emit("new-game") sends an event up to its parent game, and inside game.js we need to handle the event. When an event new-game is received, the game component will call newGame() method, which resets the board and seed two tiles by calling seedTwo() twice.

((() => {
  const html = `
    <div class="game">
      <game-menu @new-game="newGame()"></game-menu>
      ...
    </div>
  `

  Vue.component("game", {
    template: html,
    ...
    methods: {
      seedTwo() {
        let getRandomItem = () => {
          let randomIndex = Math.floor(Math.random() * this.board.length)

          return this.board[randomIndex]
        }

        let randomItem = getRandomItem()

        while (randomItem.value != 0) {
          randomItem = getRandomItem()
        }
        

        randomItem.value = 2
      },

      newGame() {
        this.resetBoard()
        this.seedTwo()
        this.seedTwo()
      },

      resetBoard() {
        this.board = Array.apply(null, { length: 16 })
          .map(function (_, index) { 
            return {
              id: index,
              value: 0
            }
          })
      }
    }
  })
}))()

Step 3: Implment Merge and slide tiles

In this step we will:

  1. Using moveRight() as an example, understand how to merge and slide tiles algorithmically
  2. Using moveRight() as an example, implement the merge and slide
  3. Register controls in the game component using Vue mixin

We essentially have four different methods to implement moveRight, moveLeft, moveUp and moveDown. Once we implement one method, the rest of them follow the same pattern and thus easier to implement. Let's take moveRight() as an example to walk through.

moveRight() essentially includes two steps: merge and slide. In merge step, we figure out the two tiles that need to combine into one; in sldie step, we figure out the future position of each tile. Because we are moving horizontally for moveRight(), the algorithm scan through each row and trigger mergeRight then slideRight. The animation below demonstrates how the algorithm of moveRight() works on one row:

step_3_merge_and_slide

The below is the code that implements the algorithm for moveRight():



      moveRight() {
        let board = _.cloneDeep(_.chunk(this.board, 4).slice())
        for (var a = 0; a < board.length; a++) {
          this.mergeRight(board, a)
          this.slideRight(board, a)
        }
      },

      mergeRight(board, a) {
        let i = board.length - 2
        let j = board.length - 1

        // updated all the possible merge values
        // think of i, j  pointers in the board
        // if they become separate, the pointers will try from catch up
        while (i >= 0) {
          if (board[a][i].value === 0 && board[a][j].value === 0) { // if both elements are zero
            j --
            i --
          } else if (board[a][i].value === board[a][j].value) { // if two elements have same value

            this.mergeAnimationsList.push({from: (a * 4 + i), to: (a * 4 + j)})

            board[a][j].value = board[a][i].value + board[a][j].value
            board[a][i].value = 0
            j--
            i--
          } else if (board[a][j].value === 0) { // if the right most has 0
            j--
            i--
          } else if (board[a][i].value != 0 && board[a][j].value != 0 && (i + 1 == j)) { // if both are non zero and next from each other
            j--
            i--
          } else if (board[a][i].value != 0 && board[a][j].value != 0) { // if both are non zero and not next from each other
            j--
          } else if (board[a][i].value === 0) { // if the left most element is zero
            i--
          }
        }
      },

      slideRight(board, a) {
        let k = board.length - 2
        let l = board.length - 1
        while (k >= 0) {
          if (board[a][l].value !== 0) { // if right most element is 0
            l --
            k --
          } else if (board[a][l].value !== 0 && board[a][k].value !== 0) { // if right most and left most elements are not 0
            l --
            k --
          } else if (board[a][l].value === 0 && board[a][k].value === 0) { // if right most and left most elements are 0
            k --
          } else if (board[a][l].value === 0 && board[a][k].value !== 0) { // if right most element is 0 and left most element is not 0

            this.slideAnimationsList.push({from: (a * 4 + k), to: (a * 4 + l)})

            board[a][l].value = board[a][k].value + board[a][l].value
            board[a][k].value = 0
            l --
            k --
          }
        }
      },

After we implement moveRight, moveLeft, moveUp and moveDown. We can isolate all those methods in src/mixins/control.js, and then we can include the control as an mixin for game component. Now when a game is mounted, we register the controls:

((() => {
  const html = `
    <div class="game">
      <game-menu @new-game="newGame()"></game-menu>
      <div class="game-container">
        <transition-group name="tile" tag="div" class="board">
          <tile v-for="tile in board" :tile="tile" :key="tile.id"></tile>
        </transition-group>
        <div class="board shadow-board">
          <div v-for="n in board.length" :key="n" class="tile shadow-tile"></div>
        </div>
      </div>
    </div>
  `

  Vue.component("game", {
    template: html,
    mixins: [window.app.mixins.control],
    ...
  })
}))()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment