こんにちは。
初めてのAdvent Calendarの参加です。
記事中のコード内には独自のメソッドやクラスがありますが、いちいち説明してません。
気になる方はコメントください。
#はじめに
/* 読み飛ばしてもなんの問題もありません。 */
2020年、リモートワークが始まり、慣れてきた2021年のこと。
ふと思いました。
人狼がやりたい。
しかし、人狼をやろうと言い出すと、言い出しっぺは大概ゲームマスター(GM)をやらなければいけない。
いやだ。
私が人狼ゲームを楽しみたいのだ。
ならばslackbot(App)にGMをやってもらおう
人狼ゲームは1ゲームに結構時間がかかる。
人数も揃えなければいけない。
予定の調整は誰がやるのか?
言い出しっぺだ。
いやだ。
私はやりたくない。
ならば、リアルタイムで毎日人狼をやるのだ。
そうして私はリアルタイムで進む人狼ゲームをSlack上で作ることを決意したのだ。
#Slack人狼のルール
今でこそ役職はそれなりに選択できるように色々バリエーションを作ったが、最初は基本的な役職のみ。人数も9人に限定した。
人狼2名、占い師1名、霊媒師1名、騎士1名、村人3名。
SlackにはSlack人狼用にチャンネルを3つ作った。
- 村チャンネル
- 基本的にゲームはこのチャンネルで進められる。 村人同士の議論やGMから全体への通知はこのチャンネルでおこなう。
- 人狼チャンネル
- 人狼だけが参加できるチャンネル。 人狼同士の会話はこのチャンネルでおこなう。
- 墓チャンネル
- 処刑されたり襲撃された人が会話できるチャンネル。 死んだ人はこのチャンネルでのみ会話可能。
- ゲーム開始時には全員が村チャンネルに追加される。
- 人狼のみ人狼チャンネルに追加される。
- 墓チャンネルからは全員が追放される。
- ゲーム中に死んだ人は墓チャンネルに追加される。
- 村チャンネルや人狼チャンネルから抜けることはないが、会話は禁止。(ここは運用カバー)
(ゲームの動向はみたいもんね。)
##ゲームの流れ
###朝の開始
GM(App)より村チャンネルに襲撃結果の通達が行われる。
###朝の時間
夜になるまで、村人同士による会話を村チャンネルでおこなう。
また、誰か一人に投票を行う。
投票はAppのホーム画面からおこなう。
自分の投票履歴はホーム画面で確認することができる。
###夜の開始
GMより村ちゃんねるに投票結果の通達が行われる。
処刑対象者は墓チャンネルに移動(追加)する。
霊媒師には処刑対象者の人狼か否かが通達される。(AppよりDMで)
###夜の時間
朝になるまでに役職者はそれぞれ能力を使用する。
- 人狼:襲撃対象の決定
- 占い師:占い対象の決定
- 騎士:護衛対象の決定
能力の使用はすべてAppのホーム画面よりおこない、その結果はDMで通知される。
(過去の能力の使用結果はホーム画面に出てくる)
###そしてまた朝がやってくる…
うちの場合はこれを
- 朝の開始:11:00
- 夜の開始:19:00
として、リアルタイムでやりました
あ、もちろん休みの日はなしで!笑
#実装だ!
今回Appに実装するのは以下の機能である。
- ゲーム開始時の設定諸々
- 役職の配布
- チャンネルの移動
- ホーム画面の表示制御
- 役職者の能力行使
- 投票
ちなみに、朝・夜の開始はWeb-APIで実装し、タイマーで実行!
実装はNode.jsとBolt for JavaScriptでHerokuにぶち上げて、DBはHerokuのPostgreSQLでやっております。
またSlackに関する記事なので、人狼の仕組みの部分は省いておりますのであしからず:sorry:
##Home画面の実装
Appのホーム画面を開いた時にDBから諸々情報を引っ張ってこないといけないので、まずはそこを実装します。
app.event('app_home_opened', appHome.openedAppHome);
app_home_opened
をサブスクライブすることでAppのホーム画面をユーザーが開いた時にイベントを実行することができます。
openedAppHome = async ({ body, client }) => {
if (body.event.tab) {
await this.refreshAppHome(client, body.event.user);
}
};
いるのかどうかは知りませんがbody.event.tab
に値が入っているときのみ処理を実行するようにしています。
Appを開いたユーザーによってホーム画面を出し分けたいので、body.event.user
のユーザーIDを処理に渡すようにしています。
基本的には以下のようにホーム画面に表示したいBlock
を渡せばいいだけです。
client.views.publish({
token: COMMON.ENV.SLACK_BOT_TOKEN,
user_id: user,
view: {
type: 'home',
blocks: [],
}
});
※たぶんtoken
はいらない
以下は色んなパターンでホーム画面を表示しています。
###ゲームが始まっていないとき
興味がある人はコードをどうぞ
refreshAppHome = async (client, user) => {
/* 中略 */
if (gameStart === 0) {
// ゲームが始まっていないときはゲームスタートボタンを表示
await client.views.publish({
token: COMMON.ENV.SLACK_BOT_TOKEN,
user_id: user,
view: {
type: 'home',
blocks: [
{
type: 'actions',
elements: [
{
type: 'button',
text: {
type: 'plain_text',
text: 'GAME START :video_game:',
emoji: true,
},
action_id: 'app_home_clicked_game_start',
style: 'primary',
},
],
},
],
},
});
return;
}
}
###ゲームは始まっているが自分は参加者じゃないとき
SlackのユーザーIDで参加者かどうかを判断しています。
###ゲーム参加者のとき
タイミングによって表示される項目は変わります。
朝の時間だと投票が、夜の時間だと役職者は能力行使のボタンが出たりします。
興味がある人はコードをどうぞ
else if (player.role_id === COMMON.ROLE.FORTUNE_TELLER.id || player.role_id === COMMON.ROLE.SAGE.id) {
/**
* 占い師・賢者の場合
* 1. 日付・役職の表示
* 2. 投票可能な場合→投票ボタン
* 3. 能力使用可能な場合→占いボタン
* 4. 占い結果の表示
* 5. 生存プレイヤーの表示
* 6. ゲーム強制終了ボタン
*/
const isSage = player.role_id === COMMON.ROLE.SAGE.id;
const sqlFortune = `SELECT ability.days, ability.target_user_id, player.name, role.is_jinro, role.role FROM ability INNER JOIN player ON ability.target_user_id = player.user_id INNER JOIN role ON player.role_id = role.id WHERE ability.role_id = ${player.role_id} ORDER BY ability.days;`;
const resultFortune = await this.db.query(sqlFortune);
const fortuneBlock = [
COMMON.makeHeaderBlock(':crystal_ball: 占い結果 :crystal_ball:'),
COMMON.makeMrkdwnBlock(
resultFortune.results.length === 0
? 'なし'
: resultFortune.results
.map(
fortune =>
`${fortune.days === 0
? '初日ランダム'
: `${fortune.days}日目`
}:${fortune.name}:${isSage ? `${fortune.role}です` :
fortune.is_jinro === 1
? '人狼です'
: '人狼ではありません'
}`
)
.join('\n')
),
COMMON.makeDividerBlock(),
];
if (
usingAbility === 1 &&
player.is_living === 1 &&
resultFortune.results.every(fortune => fortune.days !== days)
) {
abilityBlock.push({
type: 'section',
text: {
type: 'mrkdwn',
text: `【${isSage ? '賢者' : '占い師'}】占いたい人を選択することができます。`,
},
accessory: {
type: 'button',
text: {
type: 'plain_text',
text: '占い先を選択する',
emoji: true,
},
value: `${days}`,
action_id: 'app_home_clicked_fortune',
},
});
}
if (abilityBlock.length === 1) {
abilityBlock.push(COMMON.makeMrkdwnBlock('なし'));
}
abilityBlock.push(COMMON.makeDividerBlock());
await client.views.publish({
token: COMMON.ENV.SLACK_BOT_TOKEN,
user_id: user,
view: {
type: 'home',
blocks: [
...daysRoleBlock,
...livingPlayersBlock,
...abilityBlock,
...votingBlock,
...fortuneBlock,
...shutdownBlock,
],
},
});
return;
}
##ホーム画面にあるボタンの動きの実装
ホーム画面が実装出来たらもう勝ちです。
あとは各ボタンを押したときにサーバーにどう処理してもらうか、だけなのでね。
今回はだいたいホーム画面のボタンを押すとモーダルがあがってくるように作られています。
{
type: 'actions',
elements: [
{
type: 'button',
text: {
type: 'plain_text',
text: 'GAME START :video_game:',
emoji: true,
},
action_id: 'app_home_clicked_game_start',
style: 'primary',
},
],
}
button
にはaction_id
を設定しており、このaction_id
によって、サーバー側で実行する処理を決めています。
app.action('app_home_clicked_game_start', gameInit.gameStartFormOpen);
例えば、昼間の投票ボタンでは
「投票する」ボタンを押すと
このようにモーダルがあがります。
興味がある人はコードをどうぞ
app.action('app_home_clicked_voting', voting.votingFormOpen);
const votingView = {
type: 'modal',
callback_id: 'home_app_voting_view',
title: {
type: 'plain_text',
text: '投票',
emoji: true,
},
submit: {
type: 'plain_text',
text: '投票する',
emoji: true,
},
close: {
type: 'plain_text',
text: 'キャンセル',
emoji: true,
},
private_metadata: `${body.actions[0].value}`,
blocks: [
COMMON.makeMrkdwnBlock(
':warning: 投票対象および投票理由は、投票結果として全プレイヤーに公開されます。'
),
{
type: 'input',
block_id: 'select_voted_user',
element: {
type: 'static_select',
placeholder: {
type: 'plain_text',
text: '投票する人を選択',
emoji: true,
},
options: resultLiving.results.map(player => {
return {
text: {
type: 'plain_text',
text: player.name,
emoji: true,
},
value: player.user_id,
};
}),
action_id: 'voted_user_id',
},
label: {
type: 'plain_text',
text: '投票対象',
emoji: true,
},
},
{
type: 'input',
block_id: 'input_voted_reason',
element: {
type: 'plain_text_input',
multiline: true,
action_id: 'voted_reason',
},
label: {
type: 'plain_text',
text: '投票理由',
emoji: true,
},
},
],
};
await client.views.open({
// 適切な trigger_id を受け取ってから 3 秒以内に渡す
trigger_id: body.trigger_id,
// view の値をペイロードに含む
view: votingView,
});
##モーダルの動きの実装
先ほどのモーダルの「投票する」ボタンを押したときの動きはホーム画面のボタンと似たような感じですが、今回はcallback_id
に設定しており、これをキャッチします。
const votingView = {
type: 'modal',
callback_id: 'home_app_voting_view',
/* 以下略 */
}
app.view('home_app_voting_view', voting.voting);
あとはサーバー側でロジックをゴリゴリ書いています。
#さいごに
Slackで人狼をやるためにGMをアプリで作ったわけですが、基本的なホーム画面からの動きを押さえておけば、ロジックさえ組んでしまえばどんなゲームでもSlack上に作れてしまうことがわかりました。
色々なボードゲームが好きな私ですので、人狼の職業をアップデートしながら、次のSlackゲームの開発に勤しみたいと思っております!
(記事の後半の尻すぼみ感が半端ないですがご勘弁ください。)
はじめてSlack Advent Calendarに参加しましたが、なんとか間に合わせることができてよかったです。
みなさんもSlackでゲームを作りましょう!!