Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
Help us understand the problem. What is going on with this article?

Vue.js でマインスイーパーをつくる

はじめに

普段は主に Java を使っているエンジニアです。ここ数年 JavaScript に触れる機会がほとんど無く、キャッチアップのため Vue.js の勉強をしています。この記事では勉強のために作成したマインスイーパーの実装について解説します。

想定読者

  • Vue.js の基本機能を学び終わって何か作ろうと思っている人
  • マインスイーパーを作りたい人

開発環境

Vue.js 入門 で学んだ内容の復習が目的だったので、書籍に使われているバージョン2を使ってます。

vue@2.6.12

作ったもの

キャプチャ

ここでプレイできます。
https://ksugimori.github.io/vue-minesweeper

ソースコードはこちら。
https://github.com/ksugimori/vue-minesweeper

設計方針

コンポーネント分割

コンポーネントはほぼほぼ見た目通りです。ユーザーがクリックするマスひとつひとつを Cell、それらをまとめるための Field というコンポーネントを用意しています。
コンポーネント構造

レイヤー構造

コードは Model、Container、Presentation の3つのレイヤーに分けており1、それぞれ隣接するレイヤーの情報にだけ依存するようにしています。特に、ゲームのルールに関する情報やロジックは Vue コンポーネント内には持たせず、すべて Model 側に寄せています。マインスイーパーには「開いたセルが空だったらその周囲のセルも開く」というルールがありますが、これをコンポーネント側で実装するとどうしても複雑化してしまうため、このような構成にしています。
Layers.png

レイヤー 役割
Model POJO2 なオブジェクト群。
ゲームのロジックはすべてここに記述。
このプロジェクトでは Game というクラスがメイン。
Container Vue コンポーネント。
Model と直接やりとりするコンポーネント。
主に Presentation 層のコンポーネントのレイアウトを行う。
Presentation Vue コンポーネント。
情報の表示と、ユーザー操作によるイベント発火のみ。内部状態は持たない。

実装のポイント

特にこだわったところを中心に実装を説明します。

コンポーネント名については、複数単語を使うべき とされているので、コンポーネント名には Mine Sweeper の略として Ms という接頭辞を付けています。以下ではすべて、コンポーネント分割 のコンポーネントに Ms が付いた名前になっています。

また、記事の内容に関係ない部分はだいぶ端折っています。実際のコードは GitHub を見てください。

アイコン

先程の図に載せていませんが、「地雷」や「旗」などはアイコン用のコンポーネントとして作成しています。単純な図形なので CSS で描いていますが、CSS だけで図形を描くとどうしても読みづらくなってしまうので、MsCell コンポーネントから分離しています。

例として、地雷のアイコンは次のように実装しています。
中央に大きな円があって、その上に 45 度ずつ傾けた棒を4本重ねたものです。

MsIconMine.vue
<template>
  <div class="mine">
    <div class="circle" />
    <div class="bar" />
    <div class="bar" style="transform: rotate(45deg)" />
    <div class="bar" style="transform: rotate(90deg)" />
    <div class="bar" style="transform: rotate(135deg)" />
  </div>
</template>

<style scoped>
.mine > .circle {
  background-color: #35495e;
  position: absolute;
  width: 1.2rem;
  height: 1.2rem;
  top: 0.45rem;
  left: 0.45rem;
  border-radius: 0.6rem;
}
.mine > .bar {
  background-color: #35495e;
  position: absolute;
  width: 1.7rem;
  height: 0.3rem;
  top: 0.9rem;
  left: 0.2rem;
  border-radius: 0.15rem;
}
</style>

MsCell

マインスイーパーで一番重要な部品ですが、Presentation 層のコンポーネントなのでフラグによる表示の切り替え、イベント発火のみを行うシンプルなものにしています。「セルが開いているか?」というのもコンポーネントの内部状態としては持たず、バインドした変数使って表示内容を切り替えるようにしています。

アイコン類は別コンポーネントとして定義しているので、単純に v-if で表示を切り替えるだけです。

MsCell.vue
<template>
  <div @click="$emit('click')" @click.right.prevent="$emit('right-click')" >
    <div v-if="open">
      {{ count }}
      <ms-icon-mine v-if="mine" />
    </div>
    <div v-else>
      <ms-icon-flag v-if="flag" />
    </div>
  </div>
</template>

<script>
import MsIconMine from '@/components/presentations/icons/MsIconMine.vue'
import MsIconFlag from '@/components/presentations/icons/MsIconFlag.vue'

export default {
  components: { MsIconMine, MsIconFlag },
  props: {
    count: String,
    open: Boolean,
    mine: Boolean,
    flag: Boolean
  }
}
</script>

MsField

MsCell のレイアウトと、MsCell から発せられるイベントのハンドリングを行います。

MsField.vue
<template>
  <div class="field">
    <div
      v-for="(row, y) in game.field.rows"
      :key="y"
      class="row"
    >
      <ms-cell
        v-for="(cell, x) in row"
        :key="x"
        :count="cell.countString"
        :mine="cell.isMine"
        :flag="cell.isFlag"
        :open="game.state.isEnd || cell.isOpen"
        @click="game.open(x, y)"
        @right-click="game.flag(x, y)"
      />
    </div>
  </div>
</template>

<script>
import MsCell from '@/components/presentations/MsCell.vue'

export default {
  components: {
    MsCell
  },
  props: {
    game: Object
  }
}
</script>

すこしゴチャッとしてますが、重要な部分を抜き出すと次のようになります。game.field.rows には2次元配列に入ったセルの情報が入っていて、それを v-for の2重ループで並べています。3

v-forの2重ループ
<div class="field">
  <div v-for="(row, y) in game.field.rows" :key="y" >
    <ms-cell v-for="(cell, x) in row" :key="x" @click="game.open(x, y)" />
  </div>
</div>

v-for="(element, index) in array" のようにして配列のインデックスを取得できるので、クリックされたセルの座標はそこから特定しています。MsCell の props に xy を持たせても良かったのですが、「MsCell が自分の座標を知っている」というのに違和感を感じたためこのような設計になっています。

Model の実装

Model については Vue.js と無関係なオブジェクトなので、あまり深入りしません。ただ、ゲームのプログラムは State パターンの良い練習になるな、と思ったのでそこだけ記載します。

State パターン

状態(State)によって振る舞いが変化するような場合に適したデザインパターンです。詳細な説明は Wikipedia に譲りますが、コアとなる考え方は「状態によって振る舞いが変わるなら、状態を表すクラスに振る舞いを記述すれば良い」というものです。

今回実装したマインスイーパーの状態遷移図は次のようになります。INIT, PLAY, WIN, LOSE の4つの状態があり、初期状態は INIT。ゲームを開始したら PLAY。ゲームが終了したら WIN または LOSE になります。

状態 概要 左クリック
INIT 初期状態
  1. 地雷をランダムに設置
  2. タイマー開始
  3. ステータスを PLAY に変更
  4. セルを開く
  5. ゲームの終了判定
PLAY プレイ中
  1. セルを開く
  2. ゲームの終了判定
WIN ゲーム終了(勝ち) 何もしない
LOSE ゲーム終了(負け) 何もしない

ゲーム開始と同時にゲームオーバーにならないよう、初期状態では地雷はセットされていません。1つ目のセルをクリックしたときに地雷をセットし、ステータスを PLAY に切り替えます。また、勝ち負けによらずゲームが終了したあとは、セルをクリックしても何も起こりません。

Game クラスの概略は次のような構造になっています。セルがクリックされたときは MsField コンポーネントから Game#open メソッドを呼び出しますが、その処理は state に移譲しています。

Game.js
class Game {
  constructor() {
    this.state = State.INIT
  }

  // セルがクリックされたとき、MsField コンポーネントから呼ばれるメソッド
  open (x, y) {
    this.state.open(this, x, y)
  }

  // こっちは実際にセルを開く処理。state から呼ばれる
  doOpen (x, y) {
    // 省略
  }
}

ステータスの基底クラスはメソッドのインターフェースだけを定義した抽象クラスにします。45

AbstractState.js
class AbstractState {
  open (game, x, y) {
    throw new Error('メソッドが実装されていません')
  }
}

AbstractState を継承して各ステータスを表すクラスを定義。open メソッド内では game オブジェクトに対して操作を行い、必要があればステータスを更新します。ステータスごとにクラスを分けることで、先程の表や状態遷移図をほぼそのままコードに落とし込むことができます。

InitialState.js
class InitialState extends AbstractState {
 open (game, x, y) {
    // 開始準備
    game.mine(x, y) // (x, y) を避けて地雷のセット
    game.timerStart()
    game.state = State.PLAY

    // セルを開く
    game.doOpen(x, y)

    // 終了判定(一発目ですべて開く場合もあるので一応判定する)
    if (game.isWin()) {
      game.timerStop()
      game.state = State.WIN
    }
  }
}
PlayState.js
class PlayState extends AbstractState {
 open (game, x, y) {
    game.doOpen(x, y)

    // 終了判定
    if (game.isWin()) {
      game.timerStop()
      game.state = State.WIN
    } else if (game.isLose()) {
      game.timerStop()
      game.state = State.LOSE
    }
  }
}
WinState.js
class WinState extends AbstractState {
  open () {
    // 何もしない
  }
}
LoseState.js
class LoseState extends AbstractState {
  open () {
    // 何もしない
  }
}

まとめ

Vue.js の勉強としてマインスイーパーを実装してみました。

これまで jQuery ぐらいしか使えない状態だったので、こういうコンポーネント指向なライブラリでの開発は新鮮で楽しかったです。ただやはり Java の人間としては型がないと書きづらい部分もあり、TypeScript が望まれるのもわかるなあ、という感想です。


  1. 一般的に言われる Container Components, Presentational Components とは若干異なる定義かもしれません。 

  2. JavaScript でも POJO と呼ぶのかわかりません。ここでは Vue.js に依存しないピュアな JavaScript という意味で使っています。 

  3. 配列のインデックスを :key に指定すると要素の追加などで問題になるのであまり良くないらしいですが、今回は盤面のサイズが変更されたらまるごと作り直すので気にしないことにしました。 

  4. JavaScript の言語機能として抽象クラスは存在しませんが、常にエラーを投げるメソッドを定義することで継承忘れを防ぎます 

  5. 実際のコードでは Vuex も使っていて state だと紛らわしいので status という名前にしています 

kosuke-sugimori
仕事ではずっと Java. すこし COBOL. 趣味では Ruby, Go などを触ってます。最近ちょっと JavaScript の良さがわかってきた。
is-tech
大手優良企業特化型システム内製支援事業。ご連絡は https://www.is-tech.co.jp/ の問い合わせフォームをご利用ください。
https://www.is-tech.co.jp/recruit/
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