200行のVue.jsでスネークゲームを作った

こんにちは、猫チーズです。

![snake.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/268454/6b9125fd-7904-ab0e-4579-466a8ef18934.gif)

社内のVue.js勉強用プログラムとして1時間ほどで簡単なスネークゲームを作ったところ、メンバー各々が魔改造などして、一時的に社内でスネークゲームが流行りました。

（2019/09/29 追記） 第2弾も作りました → [『250行のVue.jsで陣取りゲームを作った』](https://qiita.com/miyauchoi/items/f3b382a24a1a63ecaa4f)

## デモページ
https://miyauchiakira.github.io/vue-game-snake/

## GitHub ソースコード
https://github.com/miyauchiakira/vue-game-snake

## ゲームルール
スネークゲームは、シンプルで古典的なゲームです。
1. 何も操作しないとヘビはまっすぐ進む
2. 壁か自分自身にぶつかるとゲームオーバー
3. 矢印キーでヘビを方向転換できる
4. リンゴを食べると体が伸びる＆スコアUP

これらのルールをVue.jsで作りました。

## 200行のプログラム

以下の200行のhtmlファイルに全ての機能が纏まっています。
snake.htmlなどの名前で保存して、ブラウザでそのファイルを開くと遊べます。
速さや色、新しいアイテムの追加などをして遊んでみてください。

```snake.html
<!DOCTYPE html>
<html lang="ja">
<head>
	<meta charset="UTF-8">
	<title>Snake</title>
	<script src="https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.js"></script>

	<style>
		/* グリッドレイアウト */
		#map {
			--grid-size: 10;	/* 10 x 10 マス（CSS変数） */

			display: grid;
			grid-template-columns: repeat(var(--grid-size), 30px);	/* 10列 幅30px */
			grid-template-rows: repeat(var(--grid-size), 30px);	/* 10行 高さ30px */
		}

		/* セルの色 */
		.cell {
			border: 1px solid white;
			background: whitesmoke;
		}

		/* ヘビの体の色 */
		.cell.body {
			background: darkgray;
		}

		/* フルーツの色 */
		.cell.fruit {
			background: orangered;
		}

		/* ヘビの頭の色 */
		.cell.head {
			background: dimgray;
		}
	</style>
</head>
<body>
	<div id='app'>
		<p>SCORE: {{ snake.body_indexes.length - 1 }}</p>

		<div id='map'>
			<!-- セルを100個生成して、必要に応じてhead, body, fruitクラスを付ける -->
			<!-- （注意：Vueは v-for="i in 数値" としたとき、iが1から始まる） -->
			<div v-for="i in grid_size * grid_size"
				 :class="{
					 cell: true,
					 head: snake_head_index === i - 1,
					 body: snake.body_indexes.includes(i - 1),
					 fruit: fruit_index === i - 1,
				 }"
			><!-- {{ i - 1 }} --></div>
		</div>

		<p v-if='is_gameover'>
			GAME OVER<br>
			<button onclick="location.reload()">RETRY</button>
		</p>
	</div>

	<script>
		new Vue({
			el: '#app',

			data: {
				grid_size: 10,	// 10 x 10 マス
				fruit_index: 0,	// フルーツの位置インデックス

				// ヘビに関するデータ
				snake: {

					// 頭の座標（初期値）
					head_pos: {
						x: 1,
						y: 3,
					},

					body_indexes: [0],	// 体の位置インデックスたち
					direction: '→',	// 進行方向
					speed: 400,	// 1マス進むのにかかる時間[ms]
				},
			},

			// 初期化
			created() {

				// フルーツの位置をランダムに移動
				this.randomize_fruit_index()

				// キーボード入力のイベントをon_keydownメソッドに投げる
				document.onkeydown = () => {
					this.on_keydown(event.keyCode)
				}

				// 時間を動かし始める
				this.time_goes()
			},

			watch: {
				// フルーツを食べているか監視
				is_eating_fruit(newValue) {
					if(!newValue) return	// 食べていなかったら何もしない
					this.grow_up_snake()
					this.randomize_fruit_index()
				},
			},

			computed: {

				// ヘビの頭の座標をインデックスに変換
				snake_head_index() {
					if(this.is_frameout) return null
					return this.snake.head_pos.y * this.grid_size + this.snake.head_pos.x
				},

				// フルーツ食べてる？
				is_eating_fruit() {
					return this.snake_head_index === this.fruit_index
				},

				// 自己衝突してる？
				is_suicided() {
					return this.snake.body_indexes.includes(this.snake_head_index)
				},

				// ヘビの頭は画面外？
				is_frameout() {
					const head = this.snake.head_pos
					return head.x < 0 || this.grid_size <= head.x || head.y < 0 || this.grid_size <= head.y
				},

				// ゲームオーバー？
				is_gameover() {
					return this.is_suicided || this.is_frameout
				},
			},

			methods: {
				// 時間を進める
				time_goes() {
					if(this.is_gameover) return
					this.forward_snake()

					// speedミリ秒後に自分自身を呼び出す
					setTimeout(this.time_goes.bind(this), this.snake.speed)
				},

				// ヘビを進める
				forward_snake() {

					// 体の最後尾を頭に持ってくる
					this.snake.body_indexes.shift()
					this.snake.body_indexes.push(this.snake_head_index)

					// 頭を1マス移動
					switch(this.snake.direction) {
						case '←': this.snake.head_pos.x--; break
						case '↑': this.snake.head_pos.y--; break
						case '→': this.snake.head_pos.x++; break
						case '↓': this.snake.head_pos.y++; break
					}
				},

				// ヘビの体を伸ばす
				grow_up_snake() {
					this.snake.body_indexes.unshift(this.snake.body_indexes[0])
				},

				// フルーツの位置をランダムに移動
				randomize_fruit_index() {
					this.fruit_index = Math.floor(Math.random() * this.grid_size * this.grid_size)	// 0 〜 99 の乱数
				},

				// キー入力を受け取ってヘビの進行方向を変える（逆方向は不可）
				on_keydown(keyCode) {
					switch(keyCode) {
						case 37:	// 「←」キーが押された
							if(this.snake.direction !== '→') { this.snake.direction = '←'; }
							break
							
						case 38:	// 「↑」キーが押された
							if(this.snake.direction !== '↓') { this.snake.direction = '↑'; }
							break
							
						case 39:	// 「→」キーが押された
							if(this.snake.direction !== '←') { this.snake.direction = '→'; }
							break
							
						case 40:	// 「↓」キーが押された
							if(this.snake.direction !== '↑') { this.snake.direction = '↓'; }
							break
					}
				},
			},
		})
	</script>
</body>
</html>
```

## 位置インデックスについて補足
<img width="366" alt="スクリーンショット 2019-09-28 11.41.12.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/268454/01d07abd-e625-c9fb-3bca-9bb50bff2c16.png">
プログラム内に出てくる **位置インデックス** というのは、各セルに割り振った番号のことです。左上の0から始まり、右下まで行くと99です。

上の画像の時点では、
ヘビの頭 `snake_head_index === 58`
ヘビの体 `snake.body_indexes === [44, 54, 55, 56, 57]`
フルーツ `fruit_index === 99`
になっています。

## 猫チーズ
高校一年の頃からアプリ作りに没頭していて、今はアプリクリエイターとして生きるために奮闘中です。
今年の4月からサイバーブレイン株式会社でデザイナーを始めました。

**Twitter | 猫チーズ**
https://twitter.com/miyauchoi

**Hello 猫チーズ | 猫チーズと擬似会話ができるブログ**
https://blog.miyauchi-akira.app/post/20190927/

**ポートフォリオ | ミヤウチアキラ**
https://miyauchi-akira.app


## 会社
サイバーブレイン株式会社では、AI Academyというサービスを作っています。

**AI Academy**
「AIを作りながらAIプログラミングを学ぼう」
https://aiacademy.jp/

Pythonを使った機械学習の教材が無料でたくさんあります。
AIに興味がある方はぜひ。

AI Academyサービス自体のフロントエンド側がVue.jsで作られています。
そのため今回は、社内メンバー（and いずれ来る後輩）のためにVue.jsの教材を作りました。