ソケット通信ゲームプログラム解説 – 「ワンナイト人狼」カード配布部分

Socket.IO ブラウザゲーム プログラム」ページで、「プレイヤーマッチングゲームの基本プログラム」を公開しています。
言語は TypeScript、サーバーは node.js で動作します。

Socket.IO ブラウザゲーム プログラム
https://bakuretuken.com/socket_game_base/

具体的にこの基本プログラムをつかってどの用に Scoket.IO ゲームを制作するのか、説明します。
昔作って公開していた「ワンナイト人狼 (*1)」の ゲーム開始時のカード配布処理 を例に説明します。

このページで作成したプログラムは、GitHub プロジェクトの「game_sample_01」ブランチに置いてあります。

game_sample_01 ブランチ
https://github.com/BakuretuKen/socket_game_base_room/tree/game_sample_01

ゲーム固有設定

「ワンナイト人狼」ゲームの設定を共通プログラム game_base.ts に記載します。
ルールについてはオフィシャルルールブックを参考にしてください。

・最小、最大ユーザー数
・役職定義
・人数別の役職デッキ(プレイヤー数 + 予備2枚)

■ game_base.ts

 const minUserCount = 3; // ゲーム開始に必要な最小ユーザー数
 const maxUserCount = 7; // 最大ユーザー数
+// 役職定義(全プレイヤー共通) key=役職ID
+const roles: { [key: number]: { name: string; code: string } } = {
+    0: { name: "村人", code: "citizen" },
+    1: { name: "占い師", code: "seer" },
+    2: { name: "怪盗", code: "thief" },
+    10: { name: "人狼", code: "werewolf" },
+};
+// 人数別の役職デッキ(プレイヤー数 + 予備2枚)
+const roleDecks: { [playerCount: number]: number[] } = {
+    3: [0, 1, 2, 10, 10],             // 村人、占い師、怪盗、人狼×2
+    4: [0, 0, 1, 2, 10, 10],          // 村人×2、占い師、怪盗、人狼×2
+    5: [0, 0, 0, 1, 2, 10, 10],       // 村人×3、占い師、怪盗、人狼×2
+    6: [0, 0, 0, 0, 1, 2, 10, 10],    // 村人×4、占い師、怪盗、人狼×2
+    7: [0, 0, 0, 0, 1, 2, 10, 10, 10], // 村人×4、占い師、怪盗、人狼×3
+};

次に game_base.ts に「自分の情報(役職等)」と「プレイヤー全員の情報」を保持する変数を追加します。

 let gameCode: string;
 let isMaster: boolean;
 let playerName: string;

+let player: {
+    roleId?: number; // 自分に配られた役職ID
+    playerIndex?: number; // players 配列内の自分の番号
+} = {};

 let players: {
     socketId: string;
     userName: string;
+    roleId?: number; // 配布された役職ID(ゲーム開始後・マスターが保持)
 }[] = [];

+let spareCards: number[] = []; // 予備カード2枚(マスターが保持)
 

プレイヤー全員の情報の同期

ゲーム開始時に「プレイヤー全員の情報」のマスターから各プレイヤーに送信します。startGame() 巻数を新規作成します。

■ game.ejs

   <ol id="waitingRoomList"></ol>
     <% if (master == "1") { %>
-      <button id="startGameButton" disabled onclick="sendMessage({action: 'START_GAME'});">ゲーム開始</button>
+      <button id="startGameButton" disabled onclick="startGame();">ゲーム開始</button>
     <% } %>

■ game_base.ts

+// ゲーム開始(マスター用・roleId を除いた players を全員に送る)
+function startGame(): void {
+    sendMessage({
+        action: "START_GAME",
+        players: players.map(function(p) {
+            return {
+                socketId: p.socketId,
+                userName: p.userName
+            };
+        })
+    });
+}

送信された「START_GAME」の受信部を作ります。
まずはマスターの処理から

■ game_master.ts

 // 受信処理(ここを編集してゲーム作成)
 (function() {
     window.receiveMessage = function(arr: RecvMessage): void {
         debugLog("recv(master):", arr);
         switch (arr.action) {
             case "START_GAME":
+                assignRoles();
                 // ゲーム開始したのでサーバーからゲームコードを削除(以降の新規参加を締め切る)
                 socket.emit("delete_code", { gameCode: gameCode });
                 hideMessages();
                 document.getElementById("waitingRoom")!.style.display = "none";
                 document.getElementById("gameScreen")!.style.display = "block";
                 document.getElementById("debugArea")!.style.display = "block";
+                showGameCards();

配役を全員に配布する assignRoles() と、画面にカードを表示する showGameCards() は後で作成します。
game_player.ts では、受け取った players 情報を players 変数に保持します。

■ game_player.ts

// 受信処理(ここを編集してゲーム作成)
(function() {
    window.receiveMessage = function(arr: RecvMessage): void {
         debugLog("recv(player):", arr);
         switch (arr.action) {
             case "START_GAME":
+                // マスターから送られた全プレイヤー情報を保持(roleId は含まれない)
+                if (arr.players) {
+                    players = arr.players;
+                }
                 hideMessages();
                 document.getElementById("waitingRoom")!.style.display = "none";
                 document.getElementById("gameScreen")!.style.display = "block";
                 document.getElementById("debugArea")!.style.display = "block";
+                showGameCards();
+                break;
         }
     };

カードのシャフルと配布

人数に応じてカードを用意し、シャフルする処理を作成します。assignRoles() 関数。
その後、sendDirectMessage で個別にプレイヤーに役職とそのプレイヤーの配列番号を配ります(ASSIGN_ROLE action)

■ game_master.ts

+// デッキをシャッフルして各プレイヤーに役職を配布し、余り2枚を予備カードに保持
+function assignRoles(): void {
+    const deckTemplate = roleDecks[players.length];
+    if (!deckTemplate) {
+        console.error("未対応の人数です: " + players.length);
+        return;
+    }
+    // コピーしてシャッフル(Fisher-Yates)
+    const deck = deckTemplate.slice();
+    for (let i = deck.length - 1; i > 0; i--) {
+        const j = Math.floor(Math.random() * (i + 1));
+        const tmp = deck[i];
+        deck[i] = deck[j];
+        deck[j] = tmp;
+    }
+    // 先頭からプレイヤー人数分を配布
+    for (let i = 0; i < players.length; i++) {
+        players[i].roleId = deck[i];
+    }
+    // 残り2枚を順番付きで予備カードに保持
+    spareCards = deck.slice(players.length);
+    debugLog("assignRoles players:", players);
+    debugLog("assignRoles spareCards:", spareCards);
+
+    // 各プレイヤーに自分の役職と配列番号を通知
+    for (let i = 0; i < players.length; i++) {
+        sendDirectMessage(players[i].socketId, {
+            action: "ASSIGN_ROLE",
+            roleId: players[i].roleId,
+            playerIndex: i
+        });
+    }
+}

送信した ASSIGN_ROLE を取得するコードを書きます。

■ game_master.ts

 // 受信処理(ここを編集してゲーム作成)
 (function() {
     window.receiveMessage = function(arr: RecvMessage): void {
         debugLog("recv(master):", arr);
         switch (arr.action) {
     ・・・
+            case "ASSIGN_ROLE":
+                player.roleId = arr.roleId;
+                player.playerIndex = arr.playerIndex;
+                console.log("roleId:", player.roleId, "playerIndex:", player.playerIndex);
+                showGameCards(); // カード表示
                 break;
         }
     };

■ game_player.ts

// 受信処理(ここを編集してゲーム作成)
(function() {
    window.receiveMessage = function(arr: RecvMessage): void {
        console.log("recv(player):", arr);
    ・・・
+            case "ASSIGN_ROLE":
+                player.roleId = arr.roleId;
+                player.playerIndex = arr.playerIndex;
+                console.log("roleId:", player.roleId, "playerIndex:", player.playerIndex);
+                showGameCards(); // カード表示
                 break;
         }
     };

カードの画面表示

カードを画面に表示する関数 showGameCards() を作成していきます。
予備カード2枚と各プレイヤーのカードをプレイヤーの名前付きで表示します。
全プレイヤーの名前は players に入っています(START_GAME で送信されてきた)。

自分のカード表示だけ、名前の右に役職を表示します。
役職のIDは、player.roleId に保持されています(ASSIGN_ROLE で送信されてきた)。
players 内の自分の位置は player.playerIndex で判断できます(ASSIGN_ROLE で送信されてきた)。

画面表示処理は、マスター、プレイヤー同じなので共有処理 game_base.ts に記載します。

■ game_base.ts

+// ゲーム画面にカードを裏面で表示(予備2枚+プレイヤー分)
+function showGameCards(): void {
+    const spareArea = document.getElementById("spareCardsArea");
+    const playerArea = document.getElementById("playerCardsArea");
+    if (!spareArea || !playerArea) {
+        return;
+    }
+
+    // 予備カード2枚を横並び
+    spareArea.innerHTML = "";
+    for (let i = 0; i < 2; i++) {
+        const card = document.createElement("div");
+        card.className = "card";
+        card.textContent = "■";
+        spareArea.appendChild(card);
+    }
+
+    // プレイヤー分のカードを縦並び(右に名前・自分のみ役職も表示)
+    playerArea.innerHTML = "";
+    players.forEach(function(p, index) {
+        const row = document.createElement("div");
+        row.className = "player-card-row";
+
+        const card = document.createElement("div");
+        card.className = "card";
+        card.textContent = "■";
+
+        const name = document.createElement("span");
+        name.className = "player-name";
+        let nameText = p.userName;
+        // 自分のカードのみ役職を名前の右に表示
+        if (player.playerIndex === index && player.roleId !== undefined && roles[player.roleId]) {
+            nameText += "(" + roles[player.roleId].name + ")";
+        }
+        name.textContent = nameText;
+
+        row.appendChild(card);
+        row.appendChild(name);
+        playerArea.appendChild(row);
+    });
+}

ケームの開始処理の一部ですが、ひとまず「カードをシャフルして、プレイヤーに配る」ところまで、完成です。

実行確認

カードが配られて、自分の画面だけ自分の役職が表示されています。

今回は「怪盗」がカード交換した際や、「占い師がカードを占った」ときは、sendDirectMessage でマスターに問い合わせる処理を考えて作りました(未実装ですが)。
どこまでマスターに問い合わせるかは、いろいろ考えられると思います。
例えば、ASSIGN_ROLE で自分のみの役職ではなく、全ユーザーの役職と残りカード情報を送れば、少なくても「占い師がカードを占った」ときはマスターへの通信が不要になります。

画面にカード画像を表示する際は、役職定義で code を持っているので、

  • citizen.png
  • seer.png
  • thief.png
  • werewolf.png

を用意して表示すると、画像ファイル管理がわかりやすいと思います。

見ての通り、この「基本プログラム」でゲームを作成する場合、
基本的には sendMessage、sendDirectメッセージで ASSIGN_ROLE のようなアクションを送信します。
受信側で受け取ったアクション毎の処理(swtich文)を書いて開発を進めていくスタイルです。

[マスター] - message → [プレイヤー(マスター含む)] 

プレイヤーの投票などのアクションは、逆向きのメッセージ送信となります。
マスターのみへの送信は players[0].soccketid へ、sendDirectメッセージで行えます。

[プレイヤー(マスター含む)]  - message → [マスター]


(*1) ワンナイト人狼は、オオカミさん氏作成のカードゲーム人狼を簡単にしたゲームです。オフィシャルWEBサイト