250行のVue.jsで陣取りゲームを作った

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

![jintori.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/268454/3112defc-bf13-a8a6-5484-ce6985d06980.gif)

黒色：ユーザー
灰色：自陣
赤色：敵
白色：敵陣
茶色：縄（自陣を広げるためのもの）

前回書いた記事[『200行のVue.jsでスネークゲームを作った』](https://qiita.com/miyauchoi/items/f753e5fa0209ab2034dd)が中々反響良さそうでしたので、Vue.js勉強用ゲーム 第2弾として陣取りゲームを作ってみました。

十数年前に何かのゲーム機で遊んだこのゲームを、うろ覚えで再現しています。
もし本家を知っている方いましたら教えてください！

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

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

## ゲームルール
1. 自陣の中では矢印キーで自由に動き回れる
2. 敵陣に入ると、縄を張りながらまっすぐ進む
3. 縄を張ったまま自陣に戻ると、自陣が増える
4. 敵が縄にぶつかるとゲームオーバー
5. 自陣を増やすとスコアUP

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

## 250行のプログラム
以下の250行のhtmlファイルに全ての機能が纏まっています。
jintori.htmlなどの名前で保存して、ブラウザでそのファイルを開くと遊べます。
機能の改造などをして遊んでみてください。

```jintori.html
<!DOCTYPE html>
<html lang="ja">
<head>
	<meta charset="UTF-8">
	<title>陣取りゲーム</title>
	<script src="https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.js"></script>

	<style>
		/* グリッドレイアウト */
		#grid {
			display: grid;
			grid-template-columns: repeat(20, 20px);	/* 横20マス 幅20px */
			grid-template-rows: repeat(20, 20px);	/* 縦20マス 高さ20px */
		}

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

		/* ユーザーの陣地の色 */
		.cell.area {
			background: darkgray;
		}

		/* ロープの色 */
		.cell.lope {
			background: burlywood;
		}

		/* ユーザーの色 */
		.cell.user {
			background: black;
		}

		/* 敵の色 */
		.cell.enemy {
			background: orangered;
		}
	</style>
</head>
<body>
	<div id='app'>
		<p>SCORE: {{ score }}</p>

		<div id='grid'>
			<!-- セルを400個生成して、必要に応じてuser, area, lope, enemyクラスを付ける -->
			<!-- （注意：Vueは v-for="i in 数値" としたとき、iが1から始まる） -->
			<div v-for="i in grid.width * grid.height"
				 :class="{
					 cell: true,
					 area: user.area_indexes.includes(i - 1),
					 lope: user.lope_indexes.includes(i - 1),
					 user: user_index === i - 1,
					 enemy: enemy_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: {
				speed: 200,	// 1マス進むのにかかる時間[ms]
				
				// グリッドのデータ
				grid: {
					width: 20,	// 横20マス
					height: 20,	// 縦20マス
				},

				// ユーザーのデータ
				user: {
					pos: { x: 0, y: 0 },	// 位置座標
					direction: { dx: 1, dy: 0 },	// 進行方向（初期値は右）
					area_indexes: [],	// ユーザーの陣地（インデックスの配列）
					lope_indexes: [],	// ロープ（インデックスの配列）
				},

				// 敵のデータ
				enemy: {
					pos: { x: 10, y: 10 },			// 位置座標
					direction: { dx: -1, dy: 1 },	// 進行方向（初期値は左下）
				},
			},

			// 初期化
			created() {

				// 上下左右端をユーザーのエリアにする
				for(let x = 0; x < this.grid.width; x++) {
					for(let y = 0; y < this.grid.height; y++) {
						const is_side = x === 0 || x === this.grid.width - 1 || y === 0 || y === this.grid.height - 1
						
						if(is_side) {
							this.user.area_indexes.push(this.pos2index({x, y}))
						}
					}
				}
				
				// キーボード入力のイベントをon_keydownメソッドに投げる
				document.onkeydown = () => {
					this.on_keydown(event.keyCode)
				}

				// 時間を動かし始める
				this.time_goes()
			},
			
			computed: {
				
				// スコア = ユーザーのエリアの量（初期の領域を除く）
				score() {
					return this.user.area_indexes.length - this.grid.width * 2 - this.grid.height * 2 + 4
				},

				// ユーザーの座標をインデックスに変換
				user_index() {
					return this.pos2index(this.user.pos)
				},

				// 敵の座標をインデックスに変換
				enemy_index() {
					return this.pos2index(this.enemy.pos)
				},

				// ゲームオーバーの条件
				is_gameover() {
					return this.user.lope_indexes.includes(this.enemy_index)
				},
			},

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

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

				// 受け取った座標をインデックスに変換する
				pos2index({x, y}) {
					return this.grid.width * y + x
				},

				// ユーザーを進める
				forward_user() {
					const old_pos = { ...this.user.pos }

					// directionの分だけ移動させる
					this.user.pos.x += this.user.direction.dx
					this.user.pos.y += this.user.direction.dy
					
					const pos_owner = this.get_cell_owner(this.user.pos)	// 現在地の所有者

					// 現在地が場外なら、位置座標を巻き戻す
					if(pos_owner === 'frameout') {
						this.user.pos = old_pos
						return
					}

					// 現在地が敵陣の中なら、ここにロープを追加
					if(pos_owner === 'enemy') {
						this.user.lope_indexes.push(this.user_index)
					}

					// ロープを伸ばしたままユーザーの陣地まで辿り着いた場合
					if(pos_owner === 'user' && this.user.lope_indexes.length) {
						
						const enemy_area = this.get_enemy_area()

						// 敵エリア以外のエリアをユーザーのエリアにする
						this.user.area_indexes = []
						this.user.lope_indexes = []
						for(let x = 0; x < this.grid.width; x++) {
							for(let y = 0; y < this.grid.height; y++) {
								
								// このセル(x, y)が敵の陣地か否か
								const is_enemy_area = enemy_area.left <= x && x <= enemy_area.right && enemy_area.top <= y && y <= enemy_area.bottom
								
								if(is_enemy_area) continue
								this.user.area_indexes.push(this.pos2index({x, y}))
							}
						}
					}
				},

				// 敵を進める
				forward_enemy() {
					const { pos, direction } = this.enemy

					// ユーザーのエリアにぶつかるなら跳ね返す
					const is_collided_x = this.get_cell_owner({ x: pos.x + direction.dx, y: pos.y }) === 'user'
					const is_collided_y = this.get_cell_owner({ x: pos.x, y: pos.y + direction.dy }) === 'user'
					if(is_collided_x) direction.dx *= -1	// 進行方向を左右反転
					if(is_collided_y) direction.dy *= -1	// 進行方向を上下反転

					// directionの分だけ移動させる
					pos.x += direction.dx
					pos.y += direction.dy
				},

				// 指定したセルの所有者を取得する
				get_cell_owner({x, y}) {
					if(x < 0 || this.grid.width <= x || y < 0 || this.grid.height <= y) return 'frameout'
					if(this.user.area_indexes.includes(this.pos2index({x, y}))) return 'user'
					if(this.user.lope_indexes.includes(this.pos2index({x, y}))) return 'lope'
					return 'enemy'
				},
				
				// 敵陣地の範囲（left, top, right, bottom）を取得する
				get_enemy_area() {
					const { x, y } = this.enemy.pos
					const area = { left: x, top: y, right: x, bottom: y }	// 初期値 = 敵自身の座標

					// 上下左右それぞれの方向で敵陣地の範囲を調べる
					while(this.get_cell_owner({x: area.left - 1, y}) === 'enemy') area.left--
					while(this.get_cell_owner({x: area.right + 1, y}) === 'enemy') area.right++
					while(this.get_cell_owner({x, y: area.top - 1}) === 'enemy') area.top--
					while(this.get_cell_owner({x, y: area.bottom + 1}) === 'enemy') area.bottom++

					return area
				},

				// キー入力を受け取ってユーザーの進行方向を変える
				on_keydown(keyCode) {
					if(this.get_cell_owner(this.user.pos) !== 'user') return	// ユーザーのエリア外では方向転換できない
					
					switch(keyCode) {
						case 37: this.user.direction = {dx: -1, dy: 0}; break	// 「←」キーが押された
						case 38: this.user.direction = {dx: 0, dy: -1}; break	// 「↑」キーが押された
						case 39: this.user.direction = {dx: 1, dy: 0}; break	// 「→」キーが押された
						case 40: this.user.direction = {dx: 0, dy: 1}; break	// 「↓」キーが押された
					}
				},
			},
		})
	</script>
</body>
</html>
```

以下の機能は私が今思いついたものですが、よければ機能追加してみてください。難易度順です。
・スピードを速くする
・配色を変える
・マップのサイズを変える
・経過時間を表示
・敵の出現位置をランダムにする
・敵を増やす

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

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

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

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