プログラミング入門者からの卒業試験は『ブラックジャック』を開発すべし
ものすごい今更なうえ何番煎じかもわからないけど 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 文字列)を返します。
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 枚(相当のオブジェクト)を返します。
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 コンポーネントを作成する。まだ中身は空でいい。
<template>
<div class="game"></div>
</template>
<script>
export default {
name: 'game'
}
</script>
これを 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
にされた時だけ裏面のカードを表示する。
<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
ちなみに:src
はv-bind
の省略記法で、省略しない場合はv-bind:src="image"
という書き方になる。
Player コンポーネント
これはある意味核になるコンポーネントで盛り沢山だ。ポイントをピックアップして見ていきたい。
<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.suit
、card.number
、card.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)した時、ルール通りカードを引く動作(終了処理)が組み込まれている。
<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 を乗せる。
メソッドの発火順は前述の通りで、最終的にplayersResult
とdealersResult
を比較して、判定結果をメッセージで表示する。
<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 の基本的なところは概ね触れたのではないだろうか。