プログラミング初学者がDiscordで音ゲーIR環境を作った話
※この記事はKBD合同アドベントカレンダー企画の記事となっています。
今までに公開された記事は以下のリンクからどうぞ。
はじめに
みなさんこんにちは。BBD16thのまなとぅーんです。
突然ですが音ゲーマーの皆さん、IR、やってますか?
IRというのは(おそらく)Internet Rankingの略で、決まった期間の間に音ゲーの課題曲のスコアをみんなで詰めて競おう! みたいなイベントです。BBDのみならず、東西南北様々な音ゲーサークルでIRが開催されているのではないかなと思います。
我々BBDでも、春IRと秋IR、追いコンIR(年度の最後にその年の卒業生たちが選曲した課題曲を後輩が詰めます)、選抜IR(早慶戦出場者を決めるためのもので、これだけ空気感が違うかも)などのIRが全体で行われています。
さらに、BBDとKBMの間で交流や情報交換を目的とした様々な派生サーバーが設立されており、特定機種についてのサーバーでもちょくちょくIRが行われていたりします。ボ会IR、ギタドラ部スコアタなどなど……。
というわけでBBD、KBM内で盛んに行われているIRですが、全体で行われるものはIRに特化したサイトを使用しシステム上でスコアやランキングの管理を行っているのに対し、派生サーバーでのIRの集計は基本手作業です。これってちょっと頑張れば色々変えられるんじゃないかな~、と思った次第です。
今回はGoogle Apps Script、通称GASとGoogle Form及びGoogle SpreadSheetを用いて色々とやってみました。その備忘録的な役割の記事となっています。なお、僕自身はプログラミングを学校の授業で齧っただけで、あとは必要に応じて調べながら趣味としてやっているだけの"ガチ"のプログラミング初心者です。謙遜とかじゃなくて。しかも一時間とかそこらで作ったものなので上級者から見たら「なんでこんな回りくどいことやってんだ」みたいなことをたくさんしていると思いますがご了承ください。でもGASってそういう層でも使えるような環境が整っているので需要を理解してそうな感じが好きです。
ちなみにもともとはMinecraftで建築をしてそれを記事にしようとしていたのですが、普通に時間がなくて諦めました。
2年生IRを開催するぞ!
※かなり脱線しているのでシステムを作りたいだけの人は飛ばしてください。
さて、KBDの2年生、BBD16thとKBM14thの間ではかなりBeatmania IIDXとBMS、ちょくちょくDJMAXが流行しており、2年生サーバーでは毎晩複数人が打鍵配信を行っています。
誰かが打鍵配信を目的として通話を開始し、ぞろぞろと人が集まって打鍵したい人は別の配信をスタートし、見たい人は見て、話したいだけの人はグダグダ雑談するみたいな状況になっています。
そんな中、KBD2年生弐寺四天王を担うさち君が、「今みんなの間でこんだけ弐寺が流行ってるから、ここで一発IRをやりたいと思っているんだよね」という旨の発言をしました。皆が食いつき、あれよあれよと話が進んでいきます。(残りの四天王は二階堂とかyumaとかせよぎになるんじゃないですかね、知らんけど)
すぐに話題に上がったのが集計方法をどうするか、ということ。これに自分が「Google Form使えば楽に集計できるしDiscord上にスコア速報流せるんじゃない?」と反応し、実際にやってみることにしました。
楽曲に関しては、最初から「誰でも楽しめるように☆8-10ぐらいにしておきたい」と考えていたようで、「マジで誰もやっていない曲」か「段位曲や版権曲などの全員が触れている曲」のどちらかにしたいということで、皆でがやがや話し合って☆8 Fly Above[ANOTHR]に決定されました。
ひとまずGoogle Formを作る
というわけでFormを作成。
スコアに関しては理論値より大きい数を一応入力できないようにしておきました。弐寺だと桁数が少ないので大丈夫ですが、機種によっては桁数が大きいため間違えて記載する人がいたりしそうだし、上限と下限を設定してあげてもよさそうです。
スプレッドシートの作成
Google Formではスプレッドシートに自動で回答を反映してくれる機能があるので、こちらを利用します。「回答」タブを使用すれば右上のほうにあるはず。
Discord上にスコア速報を流すのとは別に、スプレッドシート上でランキングを参照できるようにしたかったので、「データ整理用」と「ランキング」の2つのシートを作成しました。
複数回の提出を想定しているため、
①「データ整理用」でそれぞれの最高スコアと付随データを整理し、
②「ランキング」で並び替えて表示
することにしました。
「データ整理用」シート
まずは提出者を羅列することから。複数回の提出に対応するため、unique関数を使用して提出者を抜き出します。=unique([参加者名のある範囲])で重複を除いて羅列してくれます。
そして参加者たちの最高スコアを調べるため、maxifs関数を使用します。=maxifs([提出スコアのある範囲], [参加者名のある範囲], [スコアを調べたい参加者])の形で入力すれば、スコアを調べたい参加者が提出してくれたスコアで一番高いものを抜き出してきてくれます
ついでにスコアの判定も計算させておきます。これはifs関数を使用すれば楽。=ifs([条件1],[条件1に合致した場合],[条件2],[条件2に合致した場合], ……)で大丈夫です。
ここからが少し面倒だったところ。提出された回答の中から、参加者名とその最高スコアが合致する行を探してその行に書かれているコメントなどを拾ってくる必要があります。
vlookup関数というものがあって、「特定のデータが入っている行を探してきてその行の〇列目を抜き出す」というものなのですが、vlookup関数は複数条件に対応していないため、最高スコアではないスコアが提出された行から引っ張ってきてしまうんですね。
多少荒業にはなりますが、回答の反映されるシートを弄って、A列に「参加者名」+「スコア」が表記されるようにしました。(これにはGASを使用しているので後ほど解説)
データ整理用タブではすでに参加者名と最高スコアが同じ行に記載されているので、あとは=vlookup([参加者名]&[スコア], [回答データのある範囲], [データを抜き出す列], false)で抜き出してきてくれます。なお、[データを抜き出す列]は[回答データのある範囲]で指定した範囲内で左から何列目かである点に注意してください。
これをそれぞれの列に設定すれば、データ整理用シートは完成。
ちなみに#N/Aや0などの記載が大量発生しますがこれは無視してください。
ランキング
こっちは簡単、sort関数でデータ整理用シートにあるデータをスコア順に並び替えて記載してもらい、iferror関数でエラー表記を消すだけです。
=sort([データ源], [並び替えの列], false)でソートしたままデータをインポートしてくれます。falseをtrueにすると昇順になるはず。
そして=iferror([数式],"")でエラー表記を消せます。
ただ、どうしてもスコアの欄にある大量の0を消すことが出来なかったので、条件付き書式でスコアの列では0が格納されたセルは文字の色を背景と一緒にするという荒業でどうにかしました。データ整理用で0点の時は空白を表示するようにするとsort関数で並び替えるときに空白の行が上に優先されて表示しちゃうんですよね。マジで誰か助けて~……。
Google Apps Scriptを設定する
sendDiscord
まずはdiscordに送るためのfunctionを用意。これ、昔どこかのサイトから引っ張て来たやつなんですけど、この記事を書くにあたって引用元を記載しようと探しに行ったのに見つかりませんでした。
function sendDiscord(textMessage) {
if (textMessage == "") {
return;
}
let webHookUrl = "ここにwebhookのURL";
let jsonData = textMessage;
let payload = JSON.stringify(jsonData);
let options = {
method: "post",
contentType: "application/json",
payload: payload,
};
UrlFetchApp.fetch(webHookUrl, options);
}
webHookのURL取得方法は以下のリンクを参照してください。GASの編集画面の開き方とかも書いてあります。
余談ですが、自分は2年生サーバー内でwebhookを設定する権限がなかったので管理者に付与してもらったのですが、botの通知を送信するチャンネルが荒らされないようにと全メンバーの発言権限を消した結果、管理者と僕のbotだけが発言できるようになってしまいました。
onFormPost:スコア更新通知
続いてスコア提出通知をdiscordに送信するためのonFormPostを用意。
function onFormPost() {
let activeSpreadsheet = SpreadsheetApp.getActiveSpreadsheet(); // スプレッドシート
let activeSheet = activeSpreadsheet.getActiveSheet(); // アクティブシート
if (activeSheet.getName() != "フォームの回答 1") {
return;
}
let activeCell = activeSheet.getActiveCell(); // アクティブセル
let nowInputRow = activeCell.getRow(); // 入力のあった行番号
let sendText = ""
let HN = activeSheet.getRange(nowInputRow, 3).getValue(); // HN取得
let kbd = activeSheet.getRange(nowInputRow, 4).getValue(); // サークル取得
let acinf = activeSheet.getRange(nowInputRow, 5).getValue(); // 環境
let score = activeSheet.getRange(nowInputRow, 7).getValue(); // スコア
let comment = activeSheet.getRange(nowInputRow, 9).getValue(); // コメント
// 最高スコア検索用に参加者名+スコアをスプレッドシートに記入
activeSheet.getRange(nowInputRow, 1).setValue(HN + score);
// 前回提出のスコア検索用
let found = false;
let score_past = "";
let data = activeSheet.getDataRange().getValues();
for (var j = 0; j < data.length; j++) {
let row = data[j];
for (var k = 0; k < row.length; k++) {
// 今回提出のスコアが引っかからないように条件を付加する
if (row[k] == HN && j != nowInputRow - 1) {
found = true;
score_past = row[6];
break;
}
}
}
// 送信するテキスト設定
if (found) {
sendText = kbd + " " + HN + "さんが" + acinf + "にてIRスコアを提出しました\n" +
"スコア : " + score_past + " → " + score + "\n" +
"コメント : " + comment + "\n";
} else {
sendText = kbd + " " + HN + "さんが" + acinf + "にてIRスコアを提出しました\n" +
"スコア : " + score + "\n" +
"コメント : " + comment + "\n";
}
sendDiscord({
embeds: [
{
color: parseInt("ff4ca5", 16),
description: sendText
}
]
});
}
参加者名などの情報をそれぞれの変数に let ×× = activeSheet.getRange(nowInputRow, 〇).getValue(); で書き込んでもらっています。これは先ほど作ったフォームに合わせて各自改変してください。
先ほど詳細は後述とした通り、各参加者の最高スコアをvlookup関数で検索するためにフォームの回答と同じ行に参加者名+スコアを記載するため、以下の文があります。
// 最高スコア検索用に参加者名+スコアをスプレッドシートに記入
activeSheet.getRange(nowInputRow, 1).setValue(HN + score);
また、前回提出のスコアを検索して反映するために以下の文も追加しました。
// 前回提出のスコア検索用
let found = false;
let score_past = "";
let data = activeSheet.getDataRange().getValues();
for (var j = 0; j < data.length; j++) {
let row = data[j];
for (var k = 0; k < row.length; k++) {
// 今回提出のスコアが引っかからないように条件を付加する
if (row[k] == HN && j != nowInputRow - 1) {
found = true;
score_past = row[6];
break;
}
}
}
全てのセルに今回提出されたものと同じ参加者による提出がないか検索をかけ、見つけた場合にscore_postに前回スコアを代入し、フラグをtrueにするようにしています。
なお、条件文に j != nowInputRow - 1 と入れることを忘れると、今回提出のスコアが前回提出のスコアとしても扱われてしまうため注意です。
送信する文章を成形する際にフラグの状態で分岐するようにしています。
Ranking:ランキング送信用
最後にrankingを実装。これにより、現時点でのランキングを送信できます。
function ranking() {
// スプレッドシートを開く
let spreadsheet = SpreadsheetApp.openById("ここにスプレッドシートのURLの/d/以降を記載");
// シートを取得
let sheet = spreadsheet.getSheetByName("ランキング");
// シートからデータを取得
let data = sheet.getDataRange().getValues();
// 配列に格納
let Ranking = "";
let RankingArray = [];
for (var i = 0; i < data.length; i++) {
RankingArray.push(data[i]);
}
for (var i = 1; i < data.length; i++) {
if (RankingArray[i][1] != "") {
Ranking += RankingArray[i][0] + " " + RankingArray[i][1] + " " + RankingArray[i][4] + "(" + RankingArray[i][5] + RankingArray[i][6] + ")" + "\n";
}
}
sendDiscord({
username: "本日のランキング",
embeds: [
{
color: parseInt("ff7f00", 16),
description: "本日のランキングは以下の通りです\n",
fields: [{
name: ":page_with_curl:Ranking",
value: Ranking,
}]
}
]
})
}
配列RankingArrayに全てのデータをぶち込み、Rankingに文章として成形した形でどんどん追加していくようにしています。if (RankingArray[i][1] != "")と入れることで0点のデータが入っている虚無の行を一緒にぶちこまないようにしました。
こちらのfunctionでわざわざスプレッドシートのIDを記載して開かせているのは、時間主導型のトリガー(詳細は後述)を設定した際に SpreadsheetApp.getActiveSpreadsheet() が使えるか不明だったためです。試してないけどもしかしたら使えるかも。
トリガーの設定
最後にトリガーを設定します。これはGASが便利である所以の一つで、グーグルのサーバー上で勝手にプログラムを動かしてくれる機能です。秒単位の精度を担保したいなら不向きですが、今回なら十分です。
Ranking関数は以下のように設定しました。これにより毎日23時から0時の間にランキングを送信してくれます。必要に応じて頻度や時刻を飼えるといいと思います。
onFormPostは以下のように設定しました。これは必ずこの通りに設定してください。
これで動くようになるはずです。
コード全文まとめ
function sendDiscord(textMessage) {
if (textMessage == "") {
return;
}
let webHookUrl = "ここにwebhookのURL";
let jsonData = textMessage;
let payload = JSON.stringify(jsonData);
let options = {
method: "post",
contentType: "application/json",
payload: payload,
};
UrlFetchApp.fetch(webHookUrl, options);
}
function onFormPost() {
let activeSpreadsheet = SpreadsheetApp.getActiveSpreadsheet(); // スプレッドシート
let activeSheet = activeSpreadsheet.getActiveSheet(); // アクティブシート
if (activeSheet.getName() != "フォームの回答 1") {
return;
}
let activeCell = activeSheet.getActiveCell(); // アクティブセル
let nowInputRow = activeCell.getRow(); // 入力のあった行番号
let sendText = ""
let HN = activeSheet.getRange(nowInputRow, 3).getValue(); // HN取得
let kbd = activeSheet.getRange(nowInputRow, 4).getValue(); // サークル取得
let acinf = activeSheet.getRange(nowInputRow, 5).getValue(); // 環境
let score = activeSheet.getRange(nowInputRow, 7).getValue(); // スコア
let comment = activeSheet.getRange(nowInputRow, 9).getValue(); // コメント
// 最高スコア検索用に参加者名+スコアをスプレッドシートに記入
activeSheet.getRange(nowInputRow, 1).setValue(HN + score);
// 前回提出のスコア検索用
let found = false;
let score_past = "";
let data = activeSheet.getDataRange().getValues();
for (var j = 0; j < data.length; j++) {
let row = data[j];
for (var k = 0; k < row.length; k++) {
// 今回提出のスコアが引っかからないように条件を付加する
if (row[k] == HN && j != nowInputRow - 1) {
found = true;
score_past = row[6];
break;
}
}
}
// 送信するテキスト設定
if (found) {
sendText = kbd + " " + HN + "さんが" + acinf + "にてIRスコアを提出しました\n" +
"スコア : " + score_past + " → " + score + "\n" +
"コメント : " + comment + "\n";
} else {
sendText = kbd + " " + HN + "さんが" + acinf + "にてIRスコアを提出しました\n" +
"スコア : " + score + "\n" +
"コメント : " + comment + "\n";
}
sendDiscord({
embeds: [
{
color: parseInt("ff4ca5", 16),
description: sendText
}
]
});
}
function ranking() {
// スプレッドシートを開く
let spreadsheet = SpreadsheetApp.openById("ここにスプレッドシートのURLの/d/以降を記載");
// シートを取得
let sheet = spreadsheet.getSheetByName("ランキング");
// シートからデータを取得
let data = sheet.getDataRange().getValues();
// 配列に格納
let Ranking = "";
let RankingArray = [];
for (var i = 0; i < data.length; i++) {
RankingArray.push(data[i]);
}
for (var i = 1; i < data.length; i++) {
if (RankingArray[i][1] != "") {
Ranking += RankingArray[i][0] + " " + RankingArray[i][1] + " " + RankingArray[i][4] + "(" + RankingArray[i][5] + RankingArray[i][6] + ")" + "\n";
}
}
sendDiscord({
username: "本日のランキング",
embeds: [
{
color: parseInt("ff7f00", 16),
description: "本日のランキングは以下の通りです\n",
fields: [{
name: ":page_with_curl:Ranking",
value: Ranking,
}]
}
]
})
}
function onFormTest() {
sendDiscord({
embeds: [
{
color: parseInt("ff4ca5", 16),
description: "botに何か言わせたかったらここに記載して実行すればOK"
}
]
});
}
全体像はこの通りです。一応botに何でも言わせる用のonFormTestも末尾についています。
最後に
これにてひとまずの解説は終わりです。自分も遠野志貴名義でIRに参加しています。
botを作ったおかげかIR自体もかなり盛り上がってくれている気がします。まだ始まったばかりなので結果が普通に楽しみです。自分も弐寺を再開しようかな~なんて考えるようになりました。そうしたらもうちょっといいスコア出るかもだし……。
余談ですが、このbotを夜更かしして作成した結果翌朝のバイトに普通に遅刻しました。誰か俺を殺してくれ。
という訳で、「プログラミング初学者がDiscordで音ゲーIR環境を作った話」でしたっ! 一つの課題曲に真剣に取り組みつつ、みんなでワイワイできる機会って結構貴重だと思うので、いろいろな界隈でIRが流行ってほしいな~なんて思います。この記事がその一助になったら幸いです。
「うちの○○サーバーでIRやりたいんだけどこの記事読んでも作り方よくわかんなかった(´;ω;`)」「こんな機能実装してうちでも活用したい!」なんて方がいたら僕まで声をかけてくれれば喜んで飛んでいきます。お気軽にどうぞ!
では、またの機会にお会いしましょう。