6
Help us understand the problem. What are the problem?

posted at

updated at

WebSocket(Socket.IO) + Vue.js で多人数対応のオンライン対戦ゲームを作る

はじめに

WebSocketとはクライアントとサーバー間でリアルタイムに双方向に通信ができる仕組みです。
このWebSocketを簡単に扱えるライブラリ"Socket.IO"を利用して、ポケモン版Wordleのオンライン対戦ゲームを作りました。

多人数対応の解説記事があまり見つからず試行錯誤して制作したので、同じようなオンライン対戦システムを作りたい方向けに「英単語しりとり」を題材として作り方の解説を公開します。

制作環境 / ソースコード

  • Node.js 14.16.0
  • Nodemon 2.0.15
  • Socket.IO 4.4.1
  • socket.io-client 4.4.1
  • Vue.js 2.6.11
  • Vue CLI 4.5.15

セットアップ

記事の流れに沿ってコーディングする場合、以下のように環境のセットアップをしてください。
Node, Nodemon, Vue CLI を事前にインストールする必要があります。

# 作業用のディレクトリを作って、コマンドラインでそのディレクトリに移動する

# フロント側のセットアップ
vue create front
cd front
npm install socket.io-client

# サーバー側のセットアップ
cd ../
mkdir server
cd server
npm init
npm install socket.io
touch index.js

ローカル環境での動作方法

# フロント/サーバーそれぞれでターミナルソフトを1つずつ立ち上げてコマンドを実行してください

# フロント側
cd front
npm run dev

# サーバー側
cd server
nodemon index.js # 上書き保存した場合にNodeアプリケーションが自動的に再起動される

http://localhost:8080/ にアクセスするとVueのデフォルトページが開きます。

ソースコード解説

部屋を作る / 部屋に入る

まず、対戦用の部屋を作る/入るためのフロント側のUIを作ります。

front/src/App.vue
<template>
  <div id="app">
    <div>名前: <input v-model="userName" type="text" /></div>

    <input type="radio" v-model="joinType" value="1" />新しく部屋を作る
    <input type="radio" v-model="joinType" value="2" />友達の部屋に入る

    <div v-if="joinType == 1">
      <input type="button" value="部屋を作る" @click="createRoom" />
    </div>

    <div v-if="joinType == 2">
      部屋番号: <input v-model="roomId" type="text" />
      <input type="button" value="入室" @click="enterRoom" />
    </div>
  </div>
</template>

<script>
export default {
  data: () => ({
    userName: "",
    joinType: 1,
    roomId: "",
  }),
};
</script>

(部屋を作る場合)
image.png
(部屋に入る場合)
image.png

次に、サーバーを立ち上げます。

server/index.js
const http = require("http").createServer();
const io = require("socket.io")(http, {
  cors: {
    origin: ["http://localhost:8080"],
  },
});

http.listen(3031);

これでlocalhostのポート番号3031にSocket.IOのサーバーを立ち上げることができます。
CORS制約を回避するため、フロント側のアプリケーションからの接続を許可しています。
サーバー側のアプリケーションを実行していない場合は、 nodemon index.js でサーバーを立ち上げてください。

フロント側からサーバーへ接続します。

front/src/App.vue
<template>
  // 省略
</template>

<script>
import io from "socket.io-client"; // 追加

export default {
  data: () => ({
    userName: "",
    joinType: 1,
    roomId: "",
    socket: io("http://localhost:3031"), // 追加
  }),

  // 追加
  created() {
    this.socket.on("connect", () => {
      console.log("connected");
    });
  },
};
</script>

on() というメソッドがSocket.IOのイベント受信時のハンドラーで、サーバー側・フロント側どちらも同じ書き方をします。
"connect" はクライアントがサーバーに接続できた時にサーバー側から自動で送信されるイベント名です。
これでブラウザから http://localhost:8080 にアクセスするとコンソールに "connected" と出力されます。

部屋を作る処理を実装していきます。

front/src/App.vue
<template>
  // 省略
</template>

<script>
export default {
  // 中略

  // 追加
  methods: {
    createRoom() {
      this.socket.emit("create", this.userName);
    },
  },
};
</script>
server/index.js
// 前述部分省略

const rooms = [];
const users = [];

io.on("connection", (socket) => {
  // 部屋を新しく建てる
  socket.on("create", (userName) => {
    // ランダムな部屋IDを生成する(メソッドの中身は以下参照)
    // https://github.com/mega-yadoran/web-socket-online-battle/blob/master/server/index.js#L123-L131
    const roomId = generateRoomId(); 

    // ユーザー・部屋の情報をそれぞれデータとして格納する
    const user = { id: socket.id, name: userName, roomId };
    const room = {
      id: roomId,
      users: [user],
      turnUserIndex: 0,
      posts: [],
    };
    rooms.push(room);
    users.push(user);

    // 部屋に入る
    socket.join(roomId);

    // 部屋情報をクライアントに送る
    io.to(socket.id).emit("updateRoom", room);
  });
});

emit() というメソッドがSocket.IOのイベント送信処理です。受信側の on() とセットで使います。
上のコードでは、"create" というイベントをフロント側からサーバー側へ送信しています。
送信側の第2引数以降の引数が受信側のハンドラメソッドの引数として渡されます。今回の場合はuserNameですね。

ユーザーと部屋の情報をそれぞれ users , rooms という配列に格納しています。
(本格的に運用する場合はRedisなど外部のデータベースに保存する必要があると思いますが、今回は省きます)

socket.join(roomId); がSocket.IOのRoomに入るメソッドです。この仕組みによって、同じ部屋に入ってる人全員に同時にイベントを送信したりできます。

最後に io.to(socket.id).emit("updateRoom", room);"updateRoom" というイベントをサーバー側からフロント側に送信しています。
以降、フロント側の画面更新は基本的にこの "updateRoom" イベントで行っていきます。
それではフロント側に "updateRoom" イベントの受信処理を書いていきます。

front/src/App.vue
<template>
  <div id="app">
    <!-- 入室済の場合、部屋の情報を表示 -->
    <div v-if="isJoined">
      <div>{{ userName }} さん</div>
      部屋番号: {{ roomId }}
    </div>

    <!-- 未入室の場合、部屋を作る or 部屋に入るを選択 -->
    <div v-else>
      <div>名前: <input v-model="userName" type="text" /></div>
      // 省略
    </div>
  <div>
</template>

<script>
export default {
  data: () => ({
    userName: "",
    joinType: 1,
    roomId: "",
    isJoined: false, // 追加
    socket: io("http://localhost:3031"),
  }),

  created() {
    // 省略
  },

  mounted() {
    this.socket.on("updateRoom", (room) => {
      this.isJoined = true;
      this.roomId = room.id;
    });
  }
}
</script>

受信時の処理としては、サーバー側から送られてきた部屋の情報によってプロパティを更新しているだけです。

これで入室後は名前と部屋番号が表示されるようになりました。
image.png

次に、既に作られた部屋に入る処理を実装していきます。

front/src/App.vue
<script>
export default {
  methods: {
    // 追加
    enterRoom() {
      this.socket.emit("enter", this.userName, this.roomId);
    },
  },
}
</script>
server/index.js
io.on("connection", (socket) => {
  // 部屋に入室する
  socket.on("enter", (userName, roomId) => {
    const roomIndex = rooms.findIndex((r) => r.id == roomId);
    const user = { id: socket.id, name: userName, roomId };
    rooms[roomIndex].users.push(user);
    users.push(user);
    socket.join(rooms[roomIndex].id);
    io.to(socket.id).emit("updateRoom", rooms[roomIndex]);
  });
});

部屋を作る "create" のときと結構似てますね。
roomIdからroomのデータを参照して、そこへユーザーのデータを追加しています。
フロントの "updateRoom" イベント受信時の処理は特に変更する必要はありません。
これで作成済の部屋に入れるようになりました。
部屋番号さえわかれば何人でも入れるようになっています。

しかしこのままだと存在しない部屋番号を入力するとエラーで落ちてしまいます。
部屋が見つからないことをユーザー側に通知するようにします。

server/index.js
io.on("connection", (socket) => {
  // 部屋に入室する
  socket.on("enter", (userName, roomId) => {
    const roomIndex = rooms.findIndex((r) => r.id == roomId);
    if (roomIndex == -1) {
      io.to(socket.id).emit("notifyError", "部屋が見つかりません");
      return;
    }
    // 以下省略
  });
});
front/src/App.vue
<template>
  <div id="app">
    // 中略

    // 追加
    <div style="color: red">
      {{ message }}
    </div>
  </div>
</template>

<script>
export default {
  data: () => ({
    // 中略
    message: "", // 追加
  }),

  mounted() {
    this.socket.on("updateRoom", (room) => {
      // 中略
      this.message = ""; // 追加
    });

    // 追加
    this.socket.on("notifyError", (error) => {
      this.message = error;
    });
  },

  methods: {
    createRoom() {
      this.socket.emit("create", this.userName);
      this.message = ""; // 追加
    },

    enterRoom() {
      this.socket.emit("enter", this.userName, this.roomId);
      this.message = ""; // 追加
    },
}
</script>

findIndex() は配列の中に該当する要素がない場合 -1 が返却されるため、そのときに "notifyError" イベントを送信するようにしました。
フロント側で "notifyError" のイベントを受信したときに message プロパティにエラーメッセージを代入して画面表示しています。
それだけだと一度表示したエラーはずっと表示されたままになってしまうので、他のイベントの送受信時にメッセージは初期化するようにしました。
image.png
GitHubで公開しているソースコードでは名前が空欄で送信された場合にもエラー通知するようにしています。
https://github.com/mega-yadoran/web-socket-online-battle/blob/master/server/index.js#L13-L16

しりとりの英単語送信

まずはフロント側のUIを作ります

front/src/App.vue
<template>
  <div id="app">
    // 中略

    <hr />

    <!-- しりとり表示 -->
    <div v-if="isJoined">
      <!-- 入力欄 -->
      <div>
        <div>{{ turnUserName }}さんのターン:</div>

        <input type="text" v-model="input" />
        <input type="button" value="送信" @click="postWord" />
      </div>

      <!-- 入力履歴 -->
      <div v-for="(post, i) in posts" :key="i">
        <div></div>
        <div>{{ post.userName }} : " {{ post.word }} "</div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data: () => ({
    // 中略
    input: "", // 追加
    turnUserName: "", // 追加
    posts: [], // 追加
  }),

  mounted() {
    this.socket.on("updateRoom", (room) => {
      // 中略
      this.turnUserName = room.users[room.turnUserIndex].name; // 追加
      this.posts = room.posts; // 追加
      this.input = ""; // 追加
    });
  },

  methods: {
    // 追加
    postWord() {
      this.socket.emit("post", this.input);
      this.message = "";
    },
  },
},
</script>

入力した内容を "post" イベントでフロント側からサーバー側に送信します。
サーバー側で "post" イベントを受信した際に "updateRoom" イベントをサーバー側からフロント側へ送ることでゲーム画面の更新を行うため、 "updateRoom" イベント受信時に更新するプロパティを追加しています。
ゲーム中のイメージはこんな感じです↓
image.png
サーバー側の "post" イベント受信処理を実装します。

server/index.js
io.on("connection", (socket) => {
  // しりとりの単語を送信
  socket.on("post", (input) => {
    const user = users.find((u) => u.id == socket.id);
    const roomIndex = rooms.findIndex((r) => r.id == user.roomId);
    const room = rooms[roomIndex];

    // ターンプレイヤーかチェック
    if (room.users[room.turnUserIndex].id != socket.id) {
      io.to(socket.id).emit("notifyError", "あなたのターンではありません");
      return;
    }
    // 正しい入力かチェック
    if (!checkWord(input, room.posts)) {
      io.to(socket.id).emit(
        "notifyError",
        "入力が不正です。1つ前の単語の最後の文字から始まる単語を半角英字入力してください"
      );
      return;
    }
    // 単語を保存
    rooms[roomIndex].posts.unshift({
      userName: user.name,
      word: input,
    });
    // ターンプレイヤーを次のユーザーに進める
    rooms[roomIndex].turnUserIndex = getNextTurnUserIndex(room);

    io.in(room.id).emit("updateRoom", room);
  });
});

// 入力が不正な値でないかチェック
function checkWord(word, posts) {
  // 半角英字でないならNG
  if (!word.match(/^[a-z]+$/)) {
    return false;
  }
  // 1つ目の単語の場合特にチェックなしでOK
  if (posts.length == 0) {
    return true;
  }
  // 前の単語の最後の文字から始まってるならOK
  return word.slice(0, 1) == posts[0].word.slice(-1);
}

// 次のターンプレイヤーのindexを返却
function getNextTurnUserIndex(room) {
  return room.turnUserIndex == room.users.length - 1
    ? 0 // 現在のindexが末尾の場合、次を0としてターンプレイヤーをループする
    : room.turnUserIndex + 1;
}

まず、送信したクライアントのidからユーザーデータを検索し、 roomId を逆引きしています。
その後、バリデーション処理が入ります。
しりとりはターン制なのでターンプレイヤーでない場合はエラーメッセージを返しています。
既に登場した "notifyError" イベントを利用しているので、フロント側には特別な追加処理は不要です。
英単語しりとりのルールに沿っている単語かどうかを checkWord() で判定し、沿っていない場合は同様にエラーメッセージを返しています。

入力が正常な場合、単語と送信したユーザー名をroomオブジェクト内のpostsに追加します。
そしてターンプレイヤーを次のユーザーに移動します。

最後に、前述の通り "updateRoom" イベントを送信しています。
しかしイベント送信のメソッドをよく見ると io.in(room.id) となっています。
ここまでに出てきたイベント送信のメソッドは全て io.to(socket.id) でした。
to() は個別のユーザーにイベントを送信するのに対し、 in() は特定のroomにjoinしているユーザー全体に送信します。
これで同じ部屋に参加しているユーザー全体に単語が送信されたことを通知できます。

ゲームオーバーとリスタート

ここまでで一応ゲームとしては成立しますが、対戦ゲームなので勝敗の判定もつけていきたいと思います。
英単語しりとりでは一般的に "X" で終わる単語を言った人が負けになるようです。
この判定をサーバー側に追加します。

server/index.js
io.on("connection", (socket) => {
  // しりとりの単語を送信
  socket.on("post", (input) => {
    // 中略

    // 単語を保存
    rooms[roomIndex].posts.unshift({
      userName: user.name,
      word: input,
      isGameOver: checkGameOver(input), // 追加
    });
    // 以下略
  });
});

// 終了(xで終わる単語を入力したかどうか)判定
function checkGameOver(word) {
  return word.slice(-1) == "x";
}

単語データ配列の posts に ゲームオーバーになってしまう単語かどうかのフラグを追加しました。
これに基づいてフロント側の表示に変化を加えます。

front/src/App.vue
<template>
  <div id="app">
    // 中略

    <!-- しりとり表示 -->
    <div v-if="isJoined">
      <!-- ゲームオーバー時の表示 -->
      <div v-if="isGameOver">
        <div style="color: red">{{ posts[0].userName }} さんの負け</div>
        <input type="button" value="最初から" @click="restart" />
      </div>

      <!-- 入力欄 -->
      <div v-else>
        // 省略
      </div>

      <!-- 入力履歴 -->
      <div>
        // 省略
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data: () => ({
    // 中略
    isGameOver: false, // 追加
  }),

  mounted() {
    this.socket.on("updateRoom", (room) => {
      // 中略
      this.isGameOver = room.posts.length > 0 && room.posts[0].isGameOver; // 追加
    });
};
</script>

最新の単語のisGameOverフラグがtrueのときに画面表示が切り替わるようにしました。
これで誰かがxで終わる単語を送信したときに「~さんの負け」という表示が出て、それ以降の入力ができないようになりました。
image.png
「最初から」ボタンを押したときにリスタートできる機能を実装します。

front/src/App.vue
<template>
  //省略
</template>

<script>
export default {
  methods: {
    // 追加
    restart() {
      this.socket.emit("restart");
      this.message = "";
    },
  },
};
</script>
server/index.js
io.on("connection", (socket) => {
  // 最初から始める
  socket.on("restart", () => {
    const user = users.find((u) => u.id == socket.id);
    const roomIndex = rooms.findIndex((r) => r.id == user.roomId);
    const room = rooms[roomIndex];
    rooms[roomIndex].posts.length = 0;

    io.in(room.id).emit("updateRoom", room);
  });
});

フロント側は "restart" イベントを送信しているだけです。
サーバー側ではroomデータ内の posts 配列を空にして updateRoom イベントを送信しています。
これで送信履歴が消え、isGameOverフラグがfalseに更新されるので、フロント側でまた単語の入力ができるようになる、つまりゲームをリスタートした状態になります。

接続が切れた場合の対応

このままでは大きな問題があります。
誰かがブラウザを閉じるなどして途中で接続が切れた場合、そのプレイヤーのターンがきても永遠にゲームが進みません。
ということで、途中で接続が切れた場合の処理を実装していきます。

server/index.js
io.on("connection", (socket) => {
  // 接続が切れた場合
  socket.on("disconnect", () => {
    const user = users.find((u) => u.id == socket.id);
    if (!user) {
      // userデータがないときは未入室なので何もせず終了
      return;
    }
    const roomIndex = rooms.findIndex((r) => r.id == user.roomId);
    const room = rooms[roomIndex];
    const userIndex = room.users.findIndex((u) => u.id == socket.id);
    // ターンプレイヤーの場合、次のユーザーに進める
    if (userIndex == room.turnUserIndex) {
      rooms[roomIndex].turnUserIndex = getNextTurnUserIndex(room);
    }
    // ユーザーのデータを削除
    rooms[roomIndex].users.splice(userIndex, 1);
    users.splice(
      users.findIndex((u) => u.id == socket.id),
      1
    );
    // ターンプレイヤーのindexが1ズレないように補正
    if (room.turnUserIndex > userIndex) {
      rooms[roomIndex].turnUserIndex--;
    }

    io.in(room.id).emit(
      "notifyDisconnection",
      user.name,
      room.users[rooms[roomIndex].turnUserIndex].name
    );
  });
});

サーバー側の "disconnect" イベントはクライアントの接続が切れた場合に自動的に発火するイベントです。
このイベント自体はユーザーが部屋に参加しているかどうかに関わらず発火されるので、ブラウザを開いてすぐ閉じた場合など部屋に未参加の場合は以降の処理を行わないためにreturnします。

ユーザー・部屋のデータを取得した後、そのユーザーが現在のターンプレイヤーだった場合にターンプレイヤーを次のユーザーに進めています。
そしてユーザーのデータを部屋のデータから削除しています。
これによって接続が切れたユーザーがターンプレイヤーのままゲームが進まなくなることはなくなりました。

しかし、ターンプレイヤーをユーザーのindexで管理しているため少し問題があります。
例えばindexが 2 のユーザーが抜けたとすると、indexが 3 以降のユーザーのindexが全て1ずつズレます。
よって、そのindexのズレによってターンプレイヤーが変化してしまわないように turnUserIndex の値を補正しています。
このあたりはターンプレイヤーをどのように管理するかによって変わってくるので、より良い形は要検討かなと思います。
(ポケモンWordleオンラインではindexでの管理ではなくユーザーデータのオブジェクトの中に isTurnUser フラグを持たせて管理していました)

最後に "notifyDisconnection" で部屋に残っているユーザーに接続が切れたユーザーがいることを通知します。
フロント側にイベント受信時の表示処理を追加します。

front/src/App.vue
<script>
export default {
  mounted() {
    // 追加
    this.socket.on("notifyDisconnection", (userName, turnUserName) => {
      this.message = userName + " さんが退室しました";
      this.turnUserName = turnUserName;
    });  
  },
};

メッセージとターンプレイヤー名のプロパティを更新しています。
それぞれの表示個所は既に用意されているのでこれだけでOKです。
image.png

以上で完成になります。

終わりに

この記事では部屋の作成/入室/ターンの進行/リスタート/切断に分けてターン制ゲームに最低限必要な機能の実装方法を解説しました。
実際に運用するとなるともう少し考えないといけないことも出てきますが、ポケモンWordleオンラインのテスト運用時はこの記事の内容に多少のエラー処理を追加したくらいで、ほぼ同じような構成で実装していました。
(それで特に大きなバグは報告されませんでした)
なので、個人制作レベルであればこんな感じである程度問題ないんじゃないかなと思います。

この記事で制作したアプリのソースコードはご自由にお使いください。

参考記事

Register as a new user and use Qiita more conveniently

  1. You can follow users and tags
  2. you can stock useful information
  3. You can make editorial suggestions for articles
What you can do with signing up
6
Help us understand the problem. What are the problem?