Help us understand the problem. What is going on with this article?

Vue.jsでブラックジャックを作ってみた

More than 1 year has passed since last update.

プログラミング入門者からの卒業試験は『ブラックジャック』を開発すべし

ものすごい今更なうえ何番煎じかもわからないけど vue.js でブラックジャックを作ってみた。なんでかって言うと vue.js のいい感じのチュートリアルが無かったから。

完成品

デモ

https://t-kojima.github.io/blackjack-vue/

リポジトリ

https://github.com/t-kojima/blackjack-vue

ルール

ルールはネタ元にある通り、以下のものを使用。ただし A は 1 と 11 どっちでも扱えるようにする

  • 初期カードは 52 枚。引く際にカードの重複は無いようにする
  • プレイヤーとディーラーの 2 人対戦。プレイヤーは実行者、ディーラーは自動的に実行
  • 実行開始時、プレイヤーとディーラーはそれぞれ、カードを 2 枚引く。引いたカードは画面に表示する。ただし、ディーラーの 2 枚目のカードは分からないようにする
  • その後、先にプレイヤーがカードを引く。プレイヤーが 21 を超えていたらバースト、その時点でゲーム終了
  • プレイヤーは、カードを引くたびに、次のカードを引くか選択できる
  • プレイヤーが引き終えたら、その後ディーラーは、自分の手札が 17 以上になるまで引き続ける
  • プレイヤーとディーラーが引き終えたら勝負。より 21 に近い方の勝ち
  • J と Q と K は 10 として扱う
  • A はとりあえず「1」としてだけ扱う。「11」にはしない
  • ダブルダウンなし、スピリットなし、サレンダーなし、その他特殊そうなルールなし

開発環境

  • Windows 10
  • node.js 8.9.1
  • vue.js 2.5.11

プロジェクト作成

まずプロジェクトのディレクトリを作成し

mkdir blackjack-vue
cd blackjack-vue

vue-cliを使ってwebpack-simpleテンプレートを展開します。色々質問されるので全部 Yes で OK

yarn init
yarn add --dev vue-cli
yarn vue init webpack-simple .

プロジェクトのテンプレートが展開されますので、yarnでモジュールをインストールしてyarn devで開発環境のサーバを起動!

yarn
yarn dev

これでhttp://localhost:8080/にアクセスすると HelloWorld 的な画面が表示されます。

ユーティリティの作成

デッキと得点計算は複数コンポーネントで利用するので普通の JavaScript として作ります。

得点計算(calc.js)では手札を渡すと点数の配列に変換し、得点の合計(もしくは Bust 文字列)を返します。

src/utils/calc.js
export default hand => {
  const points = hand.map(card => (card.number > 10 ? 10 : card.number))
  const sum = points.reduce((ret, cur) => ret + cur)

  if (sum > 21) {
    return 'Bust'
  }
  // 合計が11以下で1(A)を含むなら+10する
  if (sum <= 11 && points.some(a => a === 1)) {
    return sum + 10
  }
  return sum
}

デッキ(deck.js)ではスートと数値の直積集合を作り、export する関数を呼ぶ度にカード 1 枚(相当のオブジェクト)を返します。

src/utils/deck.js
const deck = []
;['spade', 'club', 'diamond', 'heart'].forEach(suit => {
  Array.from(Array(13), (_, i) => ++i).forEach(number => {
    deck.push({ suit, number, hide: false })
  })
})

export default () => {
  return deck.splice(Math.floor(Math.random() * Math.floor(deck.length)), 1)[0]
}

コンポーネントの作成

いよいよ vue っぽいものを作りましょう。コンポーネントは単一ファイルコンポーネントで作成します。以下のコンポーネントをガシガシ作っていきます。

  • Game
  • Player
  • Dealer
  • Card

ちなみに css は以後全て省略するので、実装の詳細はリポジトリを見てください。

Game コンポーネント

まずは Game コンポーネントを作成する。まだ中身は空でいい。

src/components/Game.vue
<template>
  <div class="game"></div>
</template>

<script>
export default {
  name: 'game'
}
</script>

これを App.vue の子コンポーネントとして配置する。

src/App.vue
<template>
  <div id="app">
    <game />
  </div>
</template>

<script>
import Game from './components/Game'

export default {
  name: 'app',
  components: { Game },
  data () {
    return {
      msg: 'Welcome to Your Vue.js App'
    }
  }
}
</script>

これで土台ができた。以後 Game コンポーネントをベースに他のコンポーネントを配置していく。

Card コンポーネント

画面に表示されるカード 1 枚をコンポーネントで作成する。

特に動きはなく、親コンポーネントから受け取るprops(number と suit と hide)に応じて画像を表示するだけだ。hide はtrueにされた時だけ裏面のカードを表示する。

src/components/Card.vue
<template>
  <div class="card">
    <img :src="image" height="280">
  </div>
</template>

<script>
export default {
  name: 'card',
  props: {
    number: Number,
    suit: String,
    hide: Boolean,
  },
  computed: {
    image: function () {
      const filename = this.hide ? 'back' : `${this.suit}_${this.number.toString().padStart(2, "0")}`
      return require(`../assets/card_${filename}.png`)
    }
  }
}
</script>

img タグの src に image をバインドし、computed で動的にパスを作り返している。バインドされた src に式を埋め込むこともできるのでそこで動的にパスを指定することもできたが、裏/表の判定など若干長くなりそうだったので computed で返すようにした。

ここでは単純なパスの文字列を返すだけではダメで、requireしてあげないといけない。

参考

http://tk2000ex.blogspot.com/2017/11/vue.html

https://vue-loader-v14.vuejs.org/ja/configurations/asset-url.html

ちなみに:srcv-bindの省略記法で、省略しない場合はv-bind:src="image"という書き方になる。

Player コンポーネント

これはある意味核になるコンポーネントで盛り沢山だ。ポイントをピックアップして見ていきたい。

src/components/player.vue
<template>
  <div class="player">
    <div class="flex">
      <card v-for="(card, index) in hand" :key="index" :suit="card.suit" :number="card.number" :hide="card.hide"></card>
    </div>
    <div class="flex" v-show="showButtons">
      <button @click="hit">Hit</button>
      <button @click="stand">Stand</button>
    </div>
  </div>
</template>

<script>

import pick from '../utils/deck'
import calc from '../utils/calc'
import Card from './Card'

export default {
  name: 'player',
  components: { Card },
  props: ['showButtons'],
  data () {
    return {
      hand: [],
      result: 0,
    }
  },
  created: function () {
    this.hand.push(pick());
    this.hand.push(pick());
    this.result = calc(this.hand);
  },
  methods: {
    hit () {
      this.hand.push(pick());
      this.result = calc(this.hand);
    },
    stand () {
      this.$emit('stand', this.result)
    }
  },
  watch: {
    result: function (newValue, oldValue) {
      if (newValue === 'Bust') {
        this.$emit('stand', newValue)
      }
    }
  }
}
</script>

最初にコンポーネントが作成されるとcreatedが発火する。pick()はデッキからカードを 1 枚取得する関数なので、hand に最初の手札 2 枚を持たせ、result(初期の得点)を計算する。

  data () {
    return {
      hand: [],
      result: 0,
    }
  },
  created: function () {
    this.hand.push(pick());
    this.hand.push(pick());
    this.result = calc(this.hand);
  },

hand にカードデータが入ったらv-forでループし Card コンポーネントを描画する。

この時、card.suitcard.numbercard.hideをバインドして Card コンポーネントの props に渡す。すると前述のように動的にパスを生成して画像を表示する仕組みだ。

<div class="flex">
  <card v-for="(card, index) in hand" :key="index" :suit="card.suit" :number="card.number" :hide="card.hide"></card>
</div>

続いて Hit と Stand ボタンだ。@click="method"という形で methods と紐づいている。ちなみに@v-onの省略記法で、省略しなければv-on:click="method"となる

<div class="flex" v-show="showButtons">
  <button @click="hit">Hit</button>
  <button @click="stand">Stand</button>
</div>

hit()メソッドでは単純に追加ピックを行い、得点を再計算している。stand()メソッドはゲーム自体の終了処理に移行する為、$emitで親コンポーネントのstandメソッドを発火させている。

  methods: {
    hit () {
      this.hand.push(pick());
      this.result = calc(this.hand);
    },
    stand () {
      this.$emit('stand', this.result)
    }
  },
  watch: {
    result: function (newValue, oldValue) {
      if (newValue === 'Bust') {
        this.$emit('stand', newValue)
      }
    }
  }

ただしhit()した時に Bust することもあり得る。その為 result をwatchで監視し、result==='Bust'になった時点でstand()と同様に$emitする。

Dealer コンポーネント

ディーラーも hand を持つ点はプレイヤーと同じだ。こちらにはプレイヤーが Stand (もしくは Bust)した時、ルール通りカードを引く動作(終了処理)が組み込まれている。

src/components/Dealer.vue
<template>
  <div class="dealer">
    <div class="flex">
      <card v-for="(card, index) in hand" :key="index" :suit="card.suit" :number="card.number" :hide="card.hide"></card>
    </div>
  </div>
</template>

<script>

import pick from '../utils/deck'
import calc from '../utils/calc'
import Card from './Card'

export default {
  name: 'dealer',
  components: { Card },
  data () {
    return {
      hand: []
    }
  },
  created: function () {
    this.hand.push(pick());
    this.hand.push(pick());

    this.hand[0].hide = true;

    this.$on('postexec', this.postexec)
  },
  methods: {
    postexec (playerBust) {
      this.hand[0].hide = false;
      // プレイヤーがBustしてない場合、17を超えるまでカードを引く
      while (!playerBust && calc(this.hand) < 17) {
        this.hand.push(pick())
      }
      this.$emit('result', calc(this.hand))
    }
  }
}
</script>

ここでちょっと躓く点は終了処理のトリガーが別のコンポーネントという点だ。postexec というメソッドで終了処理を行っているが、基本的には親コンポーネントからこのメソッドを実行することはできない。

そこで$refsを利用して、親コンポーネントからイベントを発火できるようにする。

子コンポーネントでイベントを定義すると…

this.$on('postexec', this.postexec)

親コンポーネントから$emitで発火できる。

this.$refs.dealer.$emit('postexec', playersResult === 'Bust')

Stand 以降のイベントの流れは以下の通りだ

Player#stand -> Game#stand -> Dealer#postexec -> Game#postexec

正直イベントの発火を繰り返してコンポーネント毎の処理をこなしていくやり方はあまり正当ではない感じがする。が、現状いい方法は思い浮かばない。

Game コンポーネント(まとめ)

最後に Game コンポーネントに Player と Dealer を乗せる。

メソッドの発火順は前述の通りで、最終的にplayersResultdealersResultを比較して、判定結果をメッセージで表示する。

src/components/Game.vue
<template>
  <div class="game">
    <dealer ref="dealer" @result="postexec" />
    <div class="message">
      {{ mainMessage }}
    </div>
    <player @stand="stand" :showButtons="showButtons" />
    <div class="message result">
      {{ resultMessage }}
    </div>
  </div>
</template>

<script>

import Dealer from './Dealer'
import Player from './Player'

export default {
  name: "game",
  components: { Dealer, Player },
  data () {
    return {
      mainMessage: 'Welcome to Black Jack',
      playersResult: 0,
      dealersResult: 0,
      showButtons: true,
    }
  },
  methods: {
    stand: function (playersResult) {
      this.playersResult = playersResult;
      this.$refs.dealer.$emit('postexec', playersResult === 'Bust')
    },
    postexec: function (dealersResult) {
      this.dealersResult = dealersResult
      this.showButtons = false
      this.mainMessage = `Dealer : ${dealersResult} / Player : ${this.playersResult}`
    },
  },
  computed: {
    resultMessage: function () {
      if (this.showButtons) {
        return ''
      }
      if (this.playersResult > this.dealersResult || this.dealersResult === 'Bust') {
        return 'You Win'
      }
      if (this.playersResult < this.dealersResult || this.playersResult === 'Bust') {
        return 'You Lose'
      }
      return 'Draw'
    }
  }
}
</script>

リロードする度に1ゲームするという単純な動きだけど、これで最低限の機能は持たせることができた。

その他

カード画像はいらすとやさんから頂きました!

さいごに

量的にも難易度的にもブラックジャックという題材はちょうど良かった。vue.js の基本的なところは概ね触れたのではないだろうか。

t2kojima
最近JavaScriptが面白い
https://t-kojima.github.io/
sonicgarden
「お客様に無駄遣いをさせない受託開発」と「習慣を変えるソフトウェアのサービス」に取り組んでいるソフトウェア企業
http://www.sonicgarden.jp
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away