Insider(ボドゲ)のDiscordbotを作ってみた

この記事はkb Advent Calendar 2020 17日目の記事です。 https://adventar.org/calendars/5280

## 目次

- discordを使ってInsider(ボドゲ)をできるようにしてみた
- Insider(ボドゲ)とは
- 実装について
- discordのどのような機能がボドゲにむいているか
- 感想

## discordを使ってInsider(ボドゲ)をできるようにしてみた

最近AmongUsっていう人狼っぽいゲームをやりました。
そのゲームで色々マイクをミュートにしたりするbot
https://github.com/denverquane/automuteus
が公開されていて、そこから自分でもbotを作ってボドゲを再現できるのでは?
と思いつき、実際に作ってみました。

想像以上に簡単に作ることが出来たので、コロナで実際に会って遊ぶのが難しい状況なので
友達とワイワイしながら作って、ワイワイ遊んでくれる人が増えたらいいなぁっと思って記事を書きました。

## Insider(ボドゲ)とは

人狼っぽいゲーム
https://oinkgames.com/ja/games/analog/insider/

## 実装について

[Glitch](https://glitch.com/) というサービスを使って簡単に開発 ~ 遊ぶ まで出来ました。
Glitchを選んだ理由は手間がすごく省けてアプリケーションの開発のみに集中できるからです。

- サーバーなどのセットアップの必要がない
- プログラムを友だちと同時編集できる
- セーブすればサーバーが自動的に再起動
- 作業内容のバージョン管理
- プログラムをボタン一つで整形してくれる
- 無料

導入までの流れは
https://note.com/bami55/n/ncc3a68652697
こちらの記事を参考にしました。

### 実装内容

とりあえず以下の内容をコピペしてもらえれば Insiderが遊べれると思います。

main.js

```js
// Response for Uptime Robot
const http = require("http");
const config = require("./config.json");

http
  .createServer(function(request, response) {
    response.writeHead(200, { "Content-Type": "text/plain" });
    response.end("Discord bot is active now \n");
  })
  .listen(3000);

// Discord bot implements
const discord = require("discord.js");
const client = new discord.Client();

const embedBuilder = (title, author) => {
  return new discord.RichEmbed()
    .setTitle(`Poll - ${title}`)
    .setFooter(`Poll created by ${author}`);
};

const defEmojiList = [
  "\u0031\u20E3",
  "\u0032\u20E3",
  "\u0033\u20E3",
  "\u0034\u20E3",
  "\u0035\u20E3",
  "\u0036\u20E3",
  "\u0037\u20E3",
  "\u0038\u20E3",
  "\u0039\u20E3",
  "\uD83D\uDD1F"
];

const pollEmbed = async (
  msg,
  title,
  options,
  timeout = 30,
  emojiList = defEmojiList.slice(),
  insider
) => {
  if (!msg && !msg.channel) return msg.reply("Channel is inaccessible.");
  if (!title) return msg.reply("Poll title is not given.");
  if (!options) return msg.reply("Poll options are not given.");
  if (options.length < 2)
    return msg.reply("Please provide more than one choice.");
  if (options.length > emojiList.length)
    return msg.reply(`Please provide ${emojiList.length} or less choices.`);

  let text = `*投票するには該当の数字をクリックしてください。\n投票時間は**${timeout} 秒です**`;

  const emojiInfo = {};
  for (const option of options) {
    const emoji = emojiList.splice(0, 1);
    emojiInfo[emoji] = { option: option, votes: 0 };
    text += `${emoji} : \`${option}\`\n\n`;
  }
  const usedEmojis = Object.keys(emojiInfo);

  const poll = await msg.channel.send(
    embedBuilder(title, msg.author.tag).setDescription(text)
  );
  for (const emoji of usedEmojis) await poll.react(emoji);

  const reactionCollector = poll.createReactionCollector(
    (reaction, user) => usedEmojis.includes(reaction.emoji.name) && !user.bot,
    timeout === 0 ? {} : { time: timeout * 1000 }
  );
  const voterInfo = new Map();
  reactionCollector.on("collect", (reaction, user) => {
    if (usedEmojis.includes(reaction.emoji.name)) {
      if (!voterInfo.has(user.id)) {
        voterInfo.set(user.id, { emoji: reaction.emoji.name });
      }
      const votedEmoji = voterInfo.get(user.id).emoji;
      emojiInfo[reaction.emoji.name].votes += 1;
    }
  });

  reactionCollector.on("dispose", (reaction, user) => {
    if (usedEmojis.includes(reaction.emoji.name)) {
      voterInfo.delete(user.id);
      emojiInfo[reaction.emoji.name].votes -= 1;
    }
  });

  reactionCollector.on("end", () => {
    text = "投票終了!!\n 結果発表!!\n\n";
    for (const emoji in emojiInfo)
      text += `\`${emojiInfo[emoji].option}\` - \`${emojiInfo[emoji].votes}\`\n\n`;
    text += `インサイダーは${insider}でした\n\n`;
    poll.delete();
    msg.channel.send(embedBuilder(title, msg.author.tag).setDescription(text));
  });
};

client.on("ready", message => {
  client.user.setPresence({ game: { name: "with discord.js" } });
  console.log("bot is ready!");
});

function sleep(time) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve();
    }, time);
  });
}

client.on("message", async message => {
  if (message.content.indexOf(config.prefix) !== 0) return;

  const args = message.content
    .slice(config.prefix.length)
    .trim()
    .split(/ +/g);
  const command = args.shift().toLowerCase();

  if (command == "play") {
    let channel = message.member.voiceChannel;
    if (channel.members.size < 3) {
      message.channel.send("起動に失敗しました。3人から始めることができます。");
      return;
    }

    const members = channel.members;
    const masterMember = members.random();
    let insiderMember = members.random();
    while (true) {
      if (masterMember !== insiderMember) break;
      insiderMember = members.random();
    }
    const masterUser = masterMember.user;
    const insiderUser = insiderMember.user;
    message.channel.send("------------------------");
    message.channel.send(`マスターは${masterUser.username}です。`);

    const themes = [
      "蛾",
      "将棋盤",
      "朝顔",
      "金太郎飴",
      "ワインレッド",
      "警備員",
      "山脈",
      "鼻毛",
      "窓枠"
    ];

    const theme = themes[Math.floor(Math.random() * themes.length)];

    members.forEach(member => {
      if (member.user.username === masterUser.username) {
        member.user.sendMessage("------------------------");
        member.user.sendMessage(`テーマは${theme}です。`);
      } else if (member.user.username === insiderUser.username) {
        member.user.sendMessage("------------------------");
        member.user.sendMessage("あなたがインサイダーです。");
        member.user.sendMessage(`テーマは${theme}です。`);
      }
    });

    await sleep(2000);
    message.channel.send("Game Start!");
    // タイマー
    await sleep(1000 * 60 * 3);
    // 投票機能
    const pollOptions = [];
    channel.members.forEach(member => {
      pollOptions.push(member.user.username);
    });
    pollEmbed(
      message,
      "test",
      pollOptions,
      30,
      defEmojiList.slice(),
      insiderUser.username
    );
  }
});

if (process.env.DISCORD_BOT_TOKEN == undefined) {
  console.log("please set ENV: DISCORD_BOT_TOKEN");
  process.exit(0);
}

client.login(process.env.DISCORD_BOT_TOKEN);

```

```json
{
  "name": "glitch-discord-bot",
  "version": "0.0.0",
  "description": "discord bot sample on Glitch",
  "main": "main.js",
  "dependencies": {
    "discord.js": "latest",
    "discord.js-poll-embed": "^1.0.2",
  },
  "devDependencies": {},
  "scripts": {
    "start": "node main.js",
    "test": "node main.js"
  }
}

```

### 遊び方

1. 遊びたい友達をdiscordのボイスチャットに入れる
1. テキストチャンネルに .play と入力する
1. botがインサイダーとマスターにお題をDMするので制限時間(3分)以内にお題を当てる
1. 投票画面が出てくるので、インサイダーだと思う人に投票する
1. 投票結果が表示される

## discordのどのような機能がボドゲにむいているか

### DM機能

他のメンバーに秘密な情報などを送ることが出来ます

```js
client.on("message", async message => {
```

```js
let channel = message.member.voiceChannel;
```

ここのmessageのところに発言した人の情報が来るので
発言内容 => 発言した人 => 発言した人がいるボイスチャンネル => ボイスチャンネルにいるメンバー
の流れでメンバーの一覧を取得します。

```js
members.forEach(member => {
      if (member.user.username === masterUser.username) {
        member.user.sendMessage("------------------------");
        member.user.sendMessage(`テーマは${theme}です。`);
      } else if (member.user.username === insiderUser.username) {
        member.user.sendMessage("------------------------");
        member.user.sendMessage("あなたがインサイダーです。");
        member.user.sendMessage(`テーマは${theme}です。`);
      }
    });
```

あとは、メンバーの一覧から該当のメンバーに

```js
member.user.sendMessage("------------------------")
```

としてあげればDMを送ることが出来ます。

### 音声再生

ボットに任意の音声を再生させることが出来ます。
ゲームの開始や終了などで盛り上げることが出来ます。

Glitchで音声を使おうとすると
1. assets から音声をアップロード
2. アップロードしたファイルをクリックしてurlを取得
3. Glitch上のtools > terminal から wget で1. でアップロードしたファイルのurlを保存する
4. プログラムからファイルのパスを指定する
の流れで出来ます。

ポイントとしては
- Glitch上のassetsはディレクトリではなかったということ、ストレージ的な扱いだった...
- 音声再生しようとしたらnode上で音声の再生ができるようにしなければいけなかった。
- 再生までのラグがあるので、音声ファイルの前後に無音時間を入れる必要がある

```js
const play = async (voiceConnection, filepath) => {
  const player = voiceConnection.playFile(filepath, { volume: 0.5 });
  await new Promise(resolve => player.on("end", resolve));
};

...

await play(voiceConnection, "./sounds/open.wav");
```

```json
{
  "name": "glitch-discord-bot",
  "version": "0.0.0",
  "description": "discord bot sample on Glitch",
  "main": "main.js",
  "dependencies": {
    "discord.js": "latest",
    "ffmpeg-static": "^4.2.7",
    "node-opus": "^0.3.3",
    "discord.js-poll-embed": "^1.0.2",
    "play-sound": "^1.1.3"
  },
  "devDependencies": {},
  "scripts": {
    "start": "node main.js",
    "test": "node main.js"
  }
}
```

### 投票

投票をdiscord上で行えれる
集計とか楽

こちらを参考にさせていただきました
https://github.com/saanuregh/discord.js-poll-embed

ポイント
- discordjsのapiが最新だと12だったので少し修正が必要だった

```js
const pollEmbed = async (
  msg,
  title,
  options,
  timeout = 30,
  emojiList = defEmojiList.slice(),
  insider
) => {
  if (!msg && !msg.channel) return msg.reply("Channel is inaccessible.");
  if (!title) return msg.reply("Poll title is not given.");
  if (!options) return msg.reply("Poll options are not given.");
  if (options.length < 2)
    return msg.reply("Please provide more than one choice.");
  if (options.length > emojiList.length)
    return msg.reply(`Please provide ${emojiList.length} or less choices.`);

  let text = `*投票するには該当の数字をクリックしてください。\n投票時間は**${timeout} 秒です**`;

  const emojiInfo = {};
  for (const option of options) {
    const emoji = emojiList.splice(0, 1);
    emojiInfo[emoji] = { option: option, votes: 0 };
    text += `${emoji} : \`${option}\`\n\n`;
  }
  const usedEmojis = Object.keys(emojiInfo);

  const poll = await msg.channel.send(
    embedBuilder(title, msg.author.tag).setDescription(text)
  );
  for (const emoji of usedEmojis) await poll.react(emoji);

  const reactionCollector = poll.createReactionCollector(
    (reaction, user) => usedEmojis.includes(reaction.emoji.name) && !user.bot,
    timeout === 0 ? {} : { time: timeout * 1000 }
  );
  const voterInfo = new Map();
  reactionCollector.on("collect", (reaction, user) => {
    if (usedEmojis.includes(reaction.emoji.name)) {
      if (!voterInfo.has(user.id)) {
        voterInfo.set(user.id, { emoji: reaction.emoji.name });
      }
      const votedEmoji = voterInfo.get(user.id).emoji;
      emojiInfo[reaction.emoji.name].votes += 1;
    }
  });

  reactionCollector.on("dispose", (reaction, user) => {
    if (usedEmojis.includes(reaction.emoji.name)) {
      voterInfo.delete(user.id);
      emojiInfo[reaction.emoji.name].votes -= 1;
    }
  });

  reactionCollector.on("end", () => {
    text = "投票終了!!\n 結果発表!!\n\n";
    for (const emoji in emojiInfo)
      text += `\`${emojiInfo[emoji].option}\` - \`${emojiInfo[emoji].votes}\`\n\n`;
    text += `インサイダーは${insider}でした\n\n`;
    poll.delete();
    msg.channel.send(embedBuilder(title, msg.author.tag).setDescription(text));
  });
};
```

## 感想

あそぶところまでやってみて、やっぱりオフラインで遊ぶのと比べると物足りなさを感じました。
けれど友達とワイワイしながら作っていく醍醐味はあると思います。
簡単なコマンドや、プログラムの勉強にもなるので
ぜひみなさんも作って遊んでみてください。

## 参考記事

- https://note.com/bami55/n/ncc3a68652697
- https://github.com/saanuregh/discord.js-poll-embed