3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Qiita全国学生対抗戦Advent Calendar 2022

Day 25

小中高生向けロボコンWROの運営に携わり得点集計システムを運用した件

Last updated at Posted at 2022-12-24

目次

タイトル 内容
1 はじめに 軽い自己紹介等
2 WROの概要 WROについての説明
3 背景・経緯 4年間のシステム開発の経緯についての説明
4 システムの概要 システム要件等について列挙
5 成果物の紹介 作製したアプリケーションを紹介
6 システムの構造・フロー システムの構造をフローに沿って解説
7 GitHubの紹介 アプリをソース含め公開したのでそのご紹介
8 さいごに 本システムを公開した理由等について

今回の記事はかなりの長編となっております。
いいね・ストック等して頂き、時間のあるときにゆっくりお読み頂ければと思います。

1. はじめに

(はじめましての方に…)
私は現在某大学に通っております電気電子系の学生です。
学科は電気電子系ですが、プログラミング等を得意としています。

今回、私が関わらせて頂いた大会 WRO (World Robot Olympiad) は、小中高生向けのロボットコンテストです。
LEGO Mindstormsというロボットキットを用いて出場する大会で、世界大会まで通じる大きな大会です。
このWROの京都公認予選会(地方大会)の技術委員長としてお仕事をしました。

詳しくは以下のページから👇

2. WROの概要

このセクションでは、WROについて説明しております。
ご存知の方は読み飛ばして頂いて結構です。

主なルール

参加者は2~3人1組のチームを組み、課されるミッションをこなすロボットを製作します。

毎年1月ごろにその年のルールが発表され、予選会(日本の場合)が7月下旬から8月上旬にかけて実施されます。
その中の優秀チームは、8月下旬から9月上旬に実施される全国大会へ進みます。
さらにその全国大会で成績が優秀だったチームは、11月ごろに実施される世界大会へ進みます。
(今年の世界大会はドイツで実施)

参加できるのは主に小中高生で、8~12歳のエレメンタリーカテゴリ、11~15歳のジュニアカテゴリ、14~19歳のシニアカテゴリと3つの年齢区分があります。
またJapanでは、中・上級者向けのエキスパート競技と初級者向けのミドル競技に分かれており、計6カテゴリが展開されています。
(なお京都予選会ではエントリー向けのエレメンタリーベーシックも含め計7カテゴリで実施。)

毎年更新されるルールは、その年のテーマ(主に環境問題・社会問題等)に沿ったものとなっています。
ただし本質的に問われていることは同じで、以下のような技術が問われます。

  • 正確なロボットの走行
  • オブジェクトの運搬
  • オブジェクトやカラータイルの色読み
  • 指定された通りの手順(アルゴリズム)

又、製作するロボットにはサイズ規定があり、一辺25cmの立方体の中に収まるサイズで無ければなりません。

ミッションは制限時間2分以内にこなす必要があり、その時間内に達成できたミッションの数に応じて点数がつきます。

ロボットの走行は2回(世界大会や一部特殊ルールだと3回)行う事ができ、そのうちの最高点がチームの持ち点となります。
最終的にチームの持ち点が高い順で順位が決まります。

同点の場合はタイムが早い方が上となりますので、すばやくミッションを行うことも大切です。
レアケースですがタイムも同じだった場合、各チームの2番目の得点を比較することになり、それも同じの場合は2番目の得点を出した時のタイムが比較されます。

実際の走行映像
(私が現役だった2018年当時の映像)

大会運営形態

今年度のWRO京都予選会の参加者は92チーム・217人で、キャパや感染症対策を考慮し、午前・午後の二部制で実施しました。
又、コートを複数台用意し、複数チームが同時並行で競技を行える状態としました。

競技コートは全部で8コートで、それら全てに主審副審が配置されています。
主審が競技の進行を行い、副審はその補佐およびチェックを行います。

主審はタブレットかスマホを持ち、採点結果を京都公認予選会独自の得点集計システムに入力していきます。
一方、副審は通常の紙の採点用紙を持ち、システムに不具合が発生した時のためのバックアップをします。

関係図.png

主審が送信したデータは得点集計システムを用いて管理され、最終的に技術委員長および大会実行委員長のもとで、成績・順位が決定されます。
又、副審が記述した紙の採点用紙も技術委員長の元へ集められ、データの照合およびトラブル発生時の修正に用います。

さらに今年度からは、選手および観客が各チームの成績および順位をリアルタイムに確認することが出来るようになっています。

関係図2.png

今回はそのWRO京都公認予選会 得点集計システムに関するお話です。

3. 背景・経緯

このセクションでは、システム開発の経緯を記しています。
システムそのものを知りたい方は読み飛ばして頂いて結構です。

システム導入初年度(2019年)

3年前、WRO京都での技術支援という記事を投稿しました。
詳しくはこちらの記事に書いておりますので、よければ合わせてお読みください🙏

私は中学・高校でロボット研究会というクラブに所属し、過去4度WROに出場しました。
2019年当時私は高校3年生で、既に現役を退いていましたが、お手伝いという形で大会に関わらせて頂いておりました。

この頃、大会参加チームの増加により得点集計が大変になっていました。
というのも、通常の場合、採点結果はのシートに記録されるため、すべてのシート(全チームで100枚前後)を目視で確認し、それをExcelに打ち込み、データ集計を行わなければなりませんでした。
又、競技が終了したらすぐに表彰式に取り掛からなければならないため、時間も非常に限られています。
結果、量的にも時間的にも迫られる厳しい作業となっていました。

これを解消するべく、WEB上で得点集計が出来るシステムを開発しました。
インターフェースとしてGoogle Formsを用い、得点計算をGoogle SpreadSheetおよびGoogle Apps Script(GAS)で行いました。
いくつかの課題はあったものの、集計時間を大幅に削減することに成功し、大会運営の円滑化に一役買うことが出来ました。
今後もシステムを継続して使用することを想定し、次年度に向けてメンテナンスを続けていました。

スクリーンショット 2022-12-06 101231.png

2年目(2020年)

2020年といえば、コロナが流行し始めた年です。
WROはオフラインでの国際大会・国内大会が全て中止となりました。
当然、地方大会も開催されませんでした。

ただ、先述のようにメンテナンスは続けており、新たなインターフェースを作製していました。
初年度はGoogle Formsのフォームを利用していましたが、表現方法が少なく、また制約も多いため、自らHTMLを書いてインターフェースを作る必要があると感じていました。
そして以下のような、Google Formsのデザインをほぼ真似たフォームが完成しました。

2020Form.png

3年目(2021年)

引き続きコロナ禍でのシーズンです。
この年より、WRO京都予選会の技術委員長として、大会運営に本格的に参加しました。
2021年は様々なロボットコンテストがオフラインでの開催を試みたものの、どれも感染拡大に阻まれ、結果オンラインになった大会が多かった印象です。
その中でもWRO京都大会は感染対策を十分に行った上で、2年ぶりの大会開催へ漕ぎ着けました。

ただ、その感染対策により午前・午後の二部制とした為、すべての参加者が同じタイミングには居ないということになってしまいました。
これにより表彰式が実施できない為、その日中に得点集計をする必要がなくなりました
結果、システムは途中で開発中止となり、2年連続で運用しないことになりました。
(ちなみに前年同様メンテナンスは続けており、新しいインターフェースも開発していました)

4年目(2022年/今年)

未だコロナとの生活は続いていますが、大会実施は昨年よりも現実味のある状態でした。
又、春の時点でWRO Japan決勝大会(全国大会)が必ずオフラインで実施すると宣言していたので、地方大会としてもオフラインで実施したい状況でした。
そこでかなり気合を入れて準備に取り組み、過去最高規模の大会を実現しました。

我々としては大会の実施自体よりも、昨年度二部制にしたことによるデメリットを無くす方向へ動いていました。
大きなデメリットとして、他チーム・他カテゴリーの観戦機会が失われているという点がありました。
引き続き二部制を継続したため、午前に参加したチームは午後の競技を観ることが出来ません。
そこで、メイン放送 + 全競技コート分計9台のカメラを設置し、Youtube liveにて生配信を行いました。

一部のコートで不具合があったものの、ほぼ全ての競技を記録しており、遠隔地での観戦、および後日の競技見直しに役立てることが出来ました。
全ての動画はアーカイブとして残っていますので、良ければこちら👇からご覧下さい。

先述のように、昨年度は大会当日に表彰式が実施されず、最終結果が出るまで少しタイムラグがありました。
今年度はその間を埋める為、また観戦のお供として観客に使って貰うために、得点集計システムを再導入しました。
実に3年ぶりの本格稼働です。

4. システムの概要

ここで改めて、システムの概要について書き記します。

行いたいこと(システム要件)

  • 大会運営円滑化のために、WEBアプリを使って得点集計を自動化
  • 審判がスマホもしくはタブレットからデータを送信
  • 送信結果を元に各チーム・ラウンドの得点を自動算出
  • 各チームの得点から最高点を取得
  • 各チームの最高点からリアルタイムランキングを作成
  • 各カテゴリのランキングを選手・観客向けに公開
  • 各チーム・ラウンドの詳細結果(どのミッションが出来ていたか等)も閲覧可能に
  • 無料 (維持費0円)で実現

アプリケーション

  • 全7カテゴリーに対し「得点入力ページ」と「ランキングページ」を作製
  • 「審判用」と「選手・観客用」の二つの入り口を用意
  • 「審判用」は全ての機能にアクセス可(得点入力ページ・ランキング・詳細結果)
  • 「選手・観客用」は「審判用」の機能制限版という立ち位置(ランキング・詳細結果を閲覧可)
  • その他細かなツール群を用意(サイドバーに発走順を記載するなど)

フロントエンド

  • HTML/CSS/Javascript にて作製
  • XfreeというフリーのWebサーバ上に配置

XfreeはレンタルサーバーのXserverの無料版です。
容量は1GBと少ないですが、HTMLサーバーであれば広告表示無しで使うことが出来ます。
Xfreeのホームページはこちら

バックエンド

  • GASによりWebアプリケーションを作製
  • HTMLからPOSTしたデータをGASで受ける
  • 各カテゴリごとにSpreadSheetを作製・GASにより得点記入(データベースのようにして扱う)
  • 得点集計チームごとの最高点算出ランキングの作製等を全て自動で行う

5. 成果物の紹介

システムの詳しい仕組みは後から説明するとして、先に完成品をお見せします。

ポータルサイト

ここがすべてのアプリケーションへの入り口となるページです。
全7カテゴリーに対して、得点を入力する Form ページと、ランキングを閲覧する Ranking のページが用意されています。

screencapture-koushiro-achioku-github-io-2022-top-2022-12-14-11_47_57 (1).png

Formページ

各チームの競技を採点し、得点を入力・送信するためのページです。
このページは機能が盛りだくさんになっていますので、以下の画像の番号順にひとつずつ説明していきます。

form_desc.png

①出走順表示機能

同じカテゴリーで複数のコートがある場合、次はどのチームの出番なのか、確認するのに手間取ることがあります。
これを簡単に確認できるように、出走順を記したサイドバーを実装しました。
開くと👇このように横から出てきます。

order.png

この出走順はGASで生成したHTMLをiframeを用いて表示しています。
これは、SpreadSheetにある出走順から取得できるようにしているためで、急な出走順変更等にも臨機応変に対応できる仕組みとなっています。

index.html
<!--横からせり出しメニュー-->
<nav>
    <div>
        <div class="btn_menu"><p style="margin: 0;"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white" width="30px" height="30px" style="padding-top: 4px;"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></p></div>
        <p style="color: white; margin: 10px;">出走順</p>
    </div>

    <!--ここでGASのHTMLを表示-->
    <iframe src="https://script.google.com/macros/s/AKfycby0kjxgbrTy5lYCgeS9j7N6HNhyKsdBjcOZWk_Bnh2kBfX0zRcLdOnu-K0gCsBE3Ujl/exec"></iframe>
</nav>
gas.html
<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <base target="_top">

    <link href="https://fonts.googleapis.com/css?family=M+PLUS+1p:500,700&display=swap" rel="stylesheet">
    <link href="https://fonts.googleapis.com/css?family=Titillium+Web:600,700&display=swap" rel="stylesheet">

    <style>
        <?!= HtmlService.createHtmlOutputFromFile('CSS').getContent(); ?>
    </style>

  </head>
  <body>
    <ul>
      <?
        // スプレッドシートからデータを取得
        var data = getData('Order');
        
        // テーブルを作成
        for(var i=1;i<data.length;i++){
          output._ = '<li>';
          output._ = data[i][0] + '  ' + data[i][1] + '  ' + data[i][2] + '  ' + data[i][3];
          output._ = '</li>';
        }
      ?>
    </ul>
  </body>
</html>
Code.js
function doGet() {
    var htmlOutput = HtmlService.createTemplateFromFile("index").evaluate();
    htmlOutput.addMetaTag('viewport','width=device-width, initial-scale=1, user-scalable=no');
    htmlOutput.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
    return htmlOutput;
}

function getSheet(name){
    // SSIDからスプレッドシートの取得
    var ssid = '1yx_MdZquMbDgQTdLdCzGDAl9PTQAOrstxAbOxj0DDb4';
    var ss = SpreadsheetApp.openById(ssid);
    // 指定されたシート名からシートを取得して返却
    var sheet = ss.getSheetByName(name);
    return sheet;
}

function getData(name) {
    // 指定したシートからデータを取得
    var values = getSheet(name).getDataRange().getValues();
    return values;
}

gas.html 内で<? ?>で囲まれており、文字色が灰色になっている部分は、GASに搭載されているスクリプトレットタグという機能で、HTML内にGASのコードを埋め込むことが出来ます。
この例では、HTML生成時にスプレッドシートのデータを二次元配列で取得し、それを表形式で描画しています。

(スクリプトレットタグに関する詳しい内容はこちら👇のページに詳しく書かれています。)

②ストップウォッチ機能

ロボットのミッションは2分の制限時間内に行う必要があります。
通常、審判はストップウォッチを持っていますが、仮に持っていなくてもアプリケーション内で測定できるようにしたものです。

obs_12-07-23.gif

Startを押すとストップウォッチが作動し、ボタンがStopに変わります。
Stopを押すとストップウォッチが止まり、Startを押してからの時間がヘッダーバーに表示されます。
さらに、Formの下の方にある⑦タイム入力欄にも、自動的に四捨五入された値が入力されます。
Resetを押すとストップウォッチはまた0から作動するようになります。

この機能はJavascriptを用いており、Startを押した時の時刻Stopを押した時の時刻の差分を取り、タイムを表示しています。

timimg.js
var timer1;

function logging(){
    //現在時刻を取得、配列に保存
    let NowTime_array = new Array(4);
    let now = new Date();
    NowTime_array[0] = now.getHours();
    NowTime_array[1] = now.getMinutes();
    NowTime_array[2] = now.getSeconds();
    NowTime_array[3] = now.getMilliseconds();
    
    //現在時刻から始動時刻の差を取る
    //マイクロ秒計算
    if(NowTime_array[3]>=StartTime_array[3]){
        RunningTime_array[3]=NowTime_array[3]-StartTime_array[3];
    }
    else{
        RunningTime_array[3]=1000+NowTime_array[3]-StartTime_array[3];
        NowTime_array[2]-=1;
    }
    //秒計算
    if(NowTime_array[2]>=StartTime_array[2]){
        RunningTime_array[2]=NowTime_array[2]-StartTime_array[2];
    }
    else{
        RunningTime_array[2]=60+NowTime_array[2]-StartTime_array[2];
        NowTime_array[1]-=1;
    }
    //分計算(60分以内が前提)
    if(NowTime_array[1]>=StartTime_array[1]){
        RunningTime_array[1]=NowTime_array[1]-StartTime_array[1];
    }
    else{
        RunningTime_array[1]=60+NowTime_array[1]-StartTime_array[1];
        NowTime_array[0]-=1;
    }
    
    //ストップウォッチの時間を配列に保存
    let min = RunningTime_array[1];
    let sec = RunningTime_array[2];
    let microsec = RunningTime_array[3];

    //小数点以下表記の修正
    if(microsec<10){
        microsec = "00" + microsec;
    }
    else if(microsec<100){
        microsec = "0" + microsec;
    }
    //秒数表記の修正
    if(sec<10){
        sec = "0" + sec;
    }

    //HTMLに表示
    document.getElementById("RunningTime-text").innerHTML = min + ":" + sec + "." + microsec;
    
    console.log(NowTime_array[1]+":"+NowTime_array[2]+"."+NowTime_array[3]);    //ロギング
}

//ストップウォッチボタンのクリックイベント
document.getElementById("start-button").onclick = function(){
    if(document.getElementById("StartTime").innerHTML=="Start:Ready"){  //ストップウォッチ始動前に押された場合
        console.log("start!");
        //現在時刻を取得、配列に保存
        let now = new Date();
        StartTime_array[0] = now.getHours();
        StartTime_array[1] = now.getMinutes();
        StartTime_array[2] = now.getSeconds();
        StartTime_array[3] = now.getMilliseconds()

        //スタート時間を表記
        document.getElementById("StartTime").innerHTML = "Start:" + StartTime_array[0] + ":" + StartTime_array[1] + ":" + StartTime_array[2] + "." + StartTime_array[3];
        document.getElementById("EndTime").innerHTML = "End:Running";
        document.getElementById("start-button-text").innerHTML="Stop";

        //1秒ごとにタイムを表示
        timer1 = setInterval(logging,1000);
    }
    else if(document.getElementById("EndTime").innerHTML == "End:Running"){  //ストップウォッチ動作中に押された場合
        console.log("end!");
        //現在時刻を取得、配列に保存
        let now = new Date();
        EndTime_array[0] = now.getHours();
        EndTime_array[1] = now.getMinutes();
        EndTime_array[2] = now.getSeconds();
        EndTime_array[3] = now.getMilliseconds()

        //終了時刻を表記
        document.getElementById("EndTime").innerHTML = "End:" + EndTime_array[0] + ":" + EndTime_array[1] + ":" + EndTime_array[2] + "." + EndTime_array[3];
        document.getElementById("start-button-text").innerHTML = "Reset";

        //終了時刻から始動時刻の差を取る
        //マイクロ秒計算
        if(EndTime_array[3]>=StartTime_array[3]){
            RunningTime_array[3]=EndTime_array[3]-StartTime_array[3];
        }
        else{
            RunningTime_array[3]=1000+EndTime_array[3]-StartTime_array[3];
            EndTime_array[2]-=1;
        }
        //秒計算
        if(EndTime_array[2]>=StartTime_array[2]){
            RunningTime_array[2]=EndTime_array[2]-StartTime_array[2];
        }
        else{
            RunningTime_array[2]=60+EndTime_array[2]-StartTime_array[2];
            EndTime_array[1]-=1;
        }
        //分計算(60分以内が前提)
        if(EndTime_array[1]>=StartTime_array[1]){
            RunningTime_array[1]=EndTime_array[1]-StartTime_array[1];
        }
        else{
            RunningTime_array[1]=60+EndTime_array[1]-StartTime_array[1];
            EndTime_array[0]-=1;
        }
        
        //ストップウォッチの時間を配列に保存
        let min_and_sec = RunningTime_array[1]*60 + RunningTime_array[2];
        let min = RunningTime_array[1];
        let sec = RunningTime_array[2];
        let microsec = RunningTime_array[3];
        
        //小数点以下表記の修正
        if(microsec<10){
            microsec = "00" + microsec;
        }
        else if(microsec<100){
            microsec = "0" + microsec;
        }
        //秒数表記の修正
        if(sec<10){
            sec = "0" + sec;
        }

        //HTMLに表示
        document.getElementById("RunningTime-text").innerHTML = min + ":" + sec + "." + microsec;

        //タイムの小数点以下を四捨五入
        if(microsec>=500){
            min_and_sec = min_and_sec + 1;
        }

        //秒数打ち込み欄に自動入力
        document.getElementById("Time-textbox").value = min_and_sec;
        document.getElementById("Time-text").innerHTML = min_and_sec;

        clearInterval(timer1);  //タイマーをリセット
    }
    else if(document.getElementById("start-button-text").innerHTML == "Reset"){  //ストップウォッチ動作後に押された場合
        //始動前の状態にリセット
        document.getElementById("start-button-text").innerHTML = "Start";
        document.getElementById("StartTime").innerHTML = "Start:Ready";
        document.getElementById("EndTime").innerHTML = "End:Ready";
        document.getElementById("RunningTime-text").innerHTML = "0:00.000";
    }
};

③チーム名入力

チーム名をプルダウンから選択します。
このプルダウンの項目については、HTMLにベタ打ちするのではなく、別のファイルに記述したものをJavascriptによりインポートしています。

(SeniorExでの例)

teamlist.js
var list = [
    {val:"1", txt:"1 GEAROBO"},
    {val:"2", txt:"2 ゆっくり同好会"},
    {val:"3", txt:"3 四風連打"},
    {val:"4", txt:"4 OTW"},
    {val:"5", txt:"5 AZ-SKY"},
    {val:"6", txt:"6 ATMOS.com"},
    {val:"7", txt:"7 BlueHornPi"},
    {val:"8", txt:"8 中八"},
    {val:"9", txt:"9 Red Clover"},
];
setting.js
//チームリストの設定
function setTeam(){
    //プルダウンリストをループ処理で値を取り出してセレクトボックスにセットする
    for(var i=0;i<list.length;i++){
        let opt = document.createElement("option");
        opt.value = list[i].val;  //value値
        opt.text = list[i].txt;   //テキスト値
        document.getElementById("Teamname_selector").appendChild(opt);
    }
};

setTeam() はHTMLファイルがロードされた時に実行される仕様です。

index.html
<body onLoad="CurrentTime();setTeam()">

これにより、チーム名の変更にも柔軟に対応できます。

④ラウンド選択

ここで何回目のトライアルかを選択します。
ただし、車検違反等でエキシビジョン競技となった場合は、何回目かを選択した上で、エキシビジョンのチェックボックスも選択します。

⑤各ミッションの採点

ここからは各ミッションの成功数を選択していきます。
ラジオボタンもしくはプルダウンメニューになっています。

⑥判定例画像の表示

時々、ミッションが成功しているかどうかの判定が難しい場合があります。
そんなときのためにルールブックには判定例の画像が載っているのですが、わざわざルールブックを開くのは面倒です。
そこで、本フォーム内にその画像を載せることにしました。
ただし、必要ないときは邪魔なので、<details>タグで隠すことにしました。
(👇こんな風に)

判定例の画像

M1.png

index.html
<details>
    <summary>判定例の画像</summary>
    <img src="../img/M1.png">
</details>

⑦タイム入力

ここにタイムを入力します。
ただし、先述の通り、アプリケーション内のストップウォッチを使った場合は自動入力されます。
また入力値は整数値に制限されています。

index.html
<!--タイム-->
<div class="section">
    <table class="section-title">
        <td style="width: 45px;"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="black" width="45px" height="45px"><path d="M0 0h24v24H0z" fill="none"/><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/><path d="M12.5 7H11v6l5.25 3.15.75-1.23-4.5-2.67z"/></svg></td>
        <td><p style="font-size: 30px;">タイム</p></td>
    </table>
    
    <!--Time-->
    <div class="cp_ipnum">
        <label class="ef">
            <table class="Table-Textbox">
                <td><input type="number" name="Time" placeholder="秒" id="Time-textbox" required></td>
                <td><p style="padding-left: 5px;"></p></td>
            </table>
        </label>
    </div>
</div>

⑧入力データ確認機能

審判がすべてのデータを入力後、選手は入力項目に間違いが無いか確認する必要があります。
その際に、ページをスクロールせずともデータを確認できるように、表に一覧表示しています。
実は全てのボタンにはイベントが設定されており、 各項目で何を選択したかそれによる点数合計点タイム 、さらに 選択エラー (あり得ない選択をしていないか)といったことを処理しており、それらの結果がこの表に現れます。

👇 入力エラーが発生した時の例

入力エラー.png

setting.js
//時計関係の配列定義
var StartTime_array = new Array(0,0,0,0);
var EndTime_array = new Array(0,0,0,0);
var RunningTime_array = new Array(0,0,0,0);

//点数計算用の配列定義
var Score_array = new Array(13).fill(0);

var caution_flag = new Array(0,0).fill(0);
var obj_mission1 = new Array(0,0,0,0).fill(0);
var obj_mission2 = new Array(0,0,0,0,0).fill(0);

//点数リセット関数
function Score_reset(){
    for(let i=0; i<Score_array.length; i++ ){ Score_array[i]=0; }
}

//点数合計関数
function Score_sum(){
    Score_array[0]=0;
    for(let i=1; i<Score_array.length; i++ ){
        Score_array[0]+=Score_array[i];
    }
}

//点数計算・確認画面生成関数
function Scoring(mission,selected){
    let each_point;
    switch(mission){
        case 1:
            each_point = 6;
            document.getElementById("check_M1-1").innerText = selected;
            document.getElementById("check_M1-1_Total").innerText = selected*each_point;
            //データ数検証
            obj_mission1[1] = selected;
            obj_mission1[0] = Number(obj_mission1[1]) + Number(obj_mission1[2]) + Number(obj_mission1[3]);
            if(Number(obj_mission1[0])<=3){
                caution_flag[0] = 0;
            }
            else{
                caution_flag[0] = 1;
            }
            break;
        case 2:
            each_point = 10;
            document.getElementById("check_M1-2").innerText = selected;
            document.getElementById("check_M1-2_Total").innerText = selected*each_point;
            //データ数検証
            obj_mission1[2] = selected;
            obj_mission1[0] = Number(obj_mission1[1]) + Number(obj_mission1[2]) + Number(obj_mission1[3]);
            if(Number(obj_mission1[0])<=3){
                caution_flag[0] = 0;
            }
            else{
                caution_flag[0] = 1;
            }
            break;
        case 3:
            each_point = 16;
            document.getElementById("check_M1-3").innerText = selected;
            document.getElementById("check_M1-3_Total").innerText = selected*each_point;
            //データ数検証
            obj_mission1[3] = selected;
            obj_mission1[0] = Number(obj_mission1[1]) + Number(obj_mission1[2]) + Number(obj_mission1[3]);
            if(Number(obj_mission1[0])<=3){
                caution_flag[0] = 0;
            }
            else{
                caution_flag[0] = 1;
            }
            break;
        case 4:
            each_point=6;
            document.getElementById("check_M2-1").innerText = selected;
            document.getElementById("check_M2-1_Total").innerText = selected*each_point;
            //データ数検証
            obj_mission2[1] = selected;
            obj_mission2[0] = Number(obj_mission2[1]) + Number(obj_mission2[2]) + Number(obj_mission2[3]) + Number(obj_mission2[4]);
            if(Number(obj_mission2[0])<=2){
                caution_flag[1] = 0;
            }
            else{
                caution_flag[1] = 1;
            }
            break;
        case 5:
            each_point=-6;
            document.getElementById("check_M2-2").innerText = selected;
            document.getElementById("check_M2-2_Total").innerText = selected*each_point;
            //データ数検証
            obj_mission2[2] = selected;
            obj_mission2[0] = Number(obj_mission2[1]) + Number(obj_mission2[2]) + Number(obj_mission2[3]) + Number(obj_mission2[4]);
            if(Number(obj_mission2[0])<=2){
                caution_flag[1] = 0;
            }
            else{
                caution_flag[1] = 1;
            }
            break;
        case 6:
            each_point=10;
            document.getElementById("check_M2-3").innerText = selected;
            document.getElementById("check_M2-3_Total").innerText = selected*each_point;
            //データ数検証
            obj_mission2[3] = selected;
            obj_mission2[0] = Number(obj_mission2[1]) + Number(obj_mission2[2]) + Number(obj_mission2[3]) + Number(obj_mission2[4]);
            if(Number(obj_mission2[0])<=2){
                caution_flag[1] = 0;
            }
            else{
                caution_flag[1] = 1;
            }
            break;
        case 7:
            each_point=14;
            document.getElementById("check_M2-4").innerText = selected;
            document.getElementById("check_M2-4_Total").innerText = selected*each_point;
            //データ数検証
            obj_mission2[4] = selected;
            obj_mission2[0] = Number(obj_mission2[1]) + Number(obj_mission2[2]) + Number(obj_mission2[3]) + Number(obj_mission2[4]);
            if(Number(obj_mission2[0])<=2){
                caution_flag[1] = 0;
            }
            else{
                caution_flag[1] = 1;
            }
            break;
        case 8:
            each_point=13;
            document.getElementById("check_M3-1").innerText = selected;
            document.getElementById("check_M3-1_Total").innerText = selected*each_point;
            break;
        case 9:
            each_point=13;
            document.getElementById("check_M4-1").innerText = selected;
            document.getElementById("check_M4-1_Total").innerText = selected*each_point;
            break;
        case 10:
            each_point=4;
            document.getElementById("check_M5-1").innerText = selected;
            document.getElementById("check_M5-1_Total").innerText = selected*each_point;
            break;
        case 11:
            each_point=2;
            document.getElementById("check_M5-2").innerText = selected;
            document.getElementById("check_M5-2_Total").innerText = selected*each_point;
            break;
        case 12:
            each_point=2;
            document.getElementById("check_M5-3").innerText = selected;
            document.getElementById("check_M5-3_Total").innerText = selected*each_point;
            break;
        default:
            console.log("ERROR!!");
            break;
    }
    Score_array[mission] = each_point * selected;
    Score_sum();
    document.getElementById("Point-text").innerHTML = Score_array[0];
    document.getElementById("Point-textbox").value = Score_array[0];

    //データ数検証
    if(caution_flag[0]+caution_flag[1]==0){
        document.getElementById("caution_text").hidden = true;
    }
    else{
        document.getElementById("caution_text").hidden = false;
    }
}

//現在時刻表示関数
function CurrentTime(){
    let now = new Date();
    let CureentTime_array = new Array(3);
    CureentTime_array[0] = now.getHours();
    CureentTime_array[1] = now.getMinutes();
    CureentTime_array[2] = now.getSeconds();

    if(CureentTime_array[1]<10){
        CureentTime_array[1]="0"+CureentTime_array[1];
    }
    if(CureentTime_array[2]<10){
        CureentTime_array[2]="0"+CureentTime_array[2];
    }

    document.getElementById("CurrentTime-text").innerHTML = CureentTime_array[0]+":"+CureentTime_array[1]+":"+CureentTime_array[2];
    setTimeout("CurrentTime()", 1000);
}

//Teamname,Round,Timeの処理
window.addEventListener('DOMContentLoaded', function(){
    var input_time = document.getElementById("Time-textbox");
    input_time.addEventListener("change",function(){
        document.getElementById("Time-text").innerHTML = input_time.value;
    });

    var select_teamname = document.getElementById("Teamname_selector");
    select_teamname.addEventListener("change",function(){
        var num = select_teamname.selectedIndex;
        document.getElementById("Teamname-text").innerHTML = select_teamname.options[num].innerText;
    });
});

function Round_set(text){
    document.getElementById("Round-text").innerHTML = text;
}

//チームリストの設定
function setTeam(){
    //プルダウンリストをループ処理で値を取り出してセレクトボックスにセットする
    for(var i=0;i<list.length;i++){
        let opt = document.createElement("option");
        opt.value = list[i].val;  //value値
        opt.text = list[i].txt;   //テキスト値
        document.getElementById("Teamname_selector").appendChild(opt);
    }
};

function reset_pass(){
    document.getElementById("Pass-textbox").value = "999999";
};

⑨選手による確認ボタン

上記の表を確認した選手は、このチェックボックスにチェックを入れることで、この結果に同意したこととみなされます。
なお、このチェックボックス自体にformの値は設定されていませんが、 required が指定されているため、チェックを入れずに値を送信することは出来ません。

index.html
<!--確認用チェックボックス-->
<label for="checkbox" class="checkbox">
    <p style="font-size: 25px; display: inline-block;">確認ボタン</p>
    <input id="checkbox" type="checkbox" class="checkbox_inner" required/>
</label>

⑩審判によるパスコード入力

選手による同意も得られたら、審判は自分のみが知る送信用パスコードを入力し、データを送信します。

Rankingページ

もう一つのメイン機能である、ランキング閲覧ページです。
この画面では、各チームの最高得点、およびその順位がリアルタイムで表示されます。
上画面では表形式で、下画面ではグラフ形式で表示しています。

screencapture-koushiro-achioku-github-io-2022-ElementaryEx-Ranking-index-html-2022-12-19-17_38_17 (1).png

こちらは実際に使用したものを観客用に公開しております。

6. システムの構造・フロー

本システムは色々な立ち位置の人と、様々なサービスが複雑に絡み合っていますので、図解して説明したいと思います。

システム構造図(全体図)

こちらが全体図になります。
全体.PNG

HTMLサーバーとして Xfree 、データ処理のスクリプトとして Google Apps Script (GAS) 、データベースとして Google SpreadSheet を用いました。

審判用アプリケーションのファイル群と選手・観客用アプリケーションのファイル群は同じサーバー内に保存してはいるものの、ドメインを変更しています。
これは入り口を明確に分け、不正アクセス等を防ぐ狙いがあります。
ただ、IPアドレスがバレたらあまり意味がないので、微妙な対策ではあると思います。

ここで、この全体図だと要素が多すぎてゴチャゴチャしてしまっているので、審判用のフロー選手・観客用のフローに分けてみたいと思います。

審判用アプリケーションのフロー

審判.PNG

こちらが審判用アプリケーションのフローに従った構造図です。

ここで、各カテゴリーごとに用意されたGoogle Driveのフォルダには、以下のようなファイルが用意されています。
GoogleDriveファイル.png

構造図に示したGASの5つのファイルSpreadSheetのファイルが保存されています。

それでは構造図に示した矢印に従って、フローを追っていきます。

審判用ポータルサイト (Xfree HTMLサーバー)

はじめに、審判は持参したスマホもしくはタブレットから審判用ポータルサイトにアクセスします。

審判用ポータル.png

各カテゴリーごとに FormRanking が用意されています。
得点の入力にFormを用い、送信したデータの確認をRankingから行います。

Form (Xfree HTMLサーバー)

先述の通り、Form(得点入力用のフォーム)に進むと、次のような画面が現れます。

SeniorEx_Form.png

ここで、データを入力していきます。
データ入力の詳細についてはこの先のセクションで説明します。

このページからデータをGASのWebアプリケーションにPOST(HTTP Request)します。
POST先として指定しているのが、22(カテゴリー名)_Mainという名前のGASのスクリプトです。

Main (GASスクリプト)

22(カテゴリー名)_Mainのスクリプトには、以下の処理が実装されています。

  • HTMLサーバーからのPOSTのデータ受け
  • SpreadSheetへのデータの書き込み
  • 書き込んだデータの処理
    • チームごとの最高点の算出
    • ランキング作製

これらをSpreadSheetに読み書きしながら、処理を行います。

スプレッドシート

SpreadSheetの1つのブック内には、以下の4つのシートが用意されています。

  • Base
  • Total
  • Rank
  • Order

SpreadSheet.png

Base には、受信したデータをそのまま書き込みます。
これは単純にログを残すという意味だけでなく、手書きで入力した採点表と比較・検証を行えるようにするためということでもあります。

Baseに書き込んだ内容を元に、データを Total にコピーします。

SpreadSheet_Total (1).png

ここでは各チーム・各ラウンドごとの点数各チームの点数を高い順にソートしたものを管理しています。
これらのデータがランキングの算出に用いられます。

SpreadSheet_Rank.png

こちらが Rank シートです。
Totalシートから最高点をコピーし、ソートを行って順位表を作成します。

改めてMain(GASスクリプト)の処理

ここで改めて、先述のスクリプトの処理について、シートと絡めて示します。

  • HTMLサーバーからのPOSTのデータ受け
  • SpreadSheetへのデータの書き込み ( Base への書き込み)
  • 書き込んだデータの処理
    • チームごとの最高点の算出 ( Total への読み書き)
    • ランキング作製 ( Rank への読み書き)

このようにして、Formからデータを送信し、GASのスクリプトでSpreadSheetへと保存、および計算処理を行っています。

Ranking (GASスクリプト)

ではここからは、SpreadSheet側からHTMLサーバーの方向へ戻っていき、Rankingがどのように表示されるかを追っていきます。

まず、22(カテゴリー名)_Rankingというスクリプト内で、SpreadSheetのRankシートからデータを取得。
以下のようなHTMLをレンダリングします。

Rank_GAS.png

これをRankingページ(Xfree HTMLサーバー)内に iframe として表示します。
ここでわざわざiframeを用いる理由ですが、上の画像のように、GASでレンダリングしたHTMLは、上部に絶対に消せないヘッダーが表示されます。

このアプリケーションは、Googleではなく、別のユーザーによって作成されたものです。

別に実害は無いのですが、ハッキリ言ってダサくて邪魔です。

これを解消する(唯一?)の方法が、
iframeでGASで生成したHTMLを埋め込む
という方法だったのです。

以上の理由から、わざわざHTMLサーバーを経由してGASの生成したランキング表を見るようにしています。

Result (GASスクリプト)

先程のランキング表の一番右の項目に、

Result
Check

という項目がありました。
このCheckはリンクとなっており、押すと各チームの採点結果が閲覧出来るようになっています。

遷移先のページは以下のようになっています。

Result_GAS.png

先述の通り、各チームのミッション成功数や合計得点、タイム等、すべての情報が閲覧出来ます。
これらのデータはSpreadSheetの Base シートおよび Total シートから取得しています。

22(カテゴリー名)_Resultスクリプトではこれらのデータを取得し、表形式にしてHTMLをレンダリングします。
そして、RankingのCheckを押したときの遷移先をResultのHTMLにすることで、各チームの結果の確認が出来るようにしています。

尚、参照チームの指定はURLクエリを用いており、それぞれのCheckボタンにIDが付与されています。

URLクエリの詳しい説明はこちら👇から

Ranking (Xfree HTMLサーバー)

先述の通り、このページではiframeを用いてランキング表を埋め込み、表示しています。

SeniorEx_Ranking (1).png

構造的には、

HTMLサーバーのHTML
L GASが生成したランキング表
  L GASが生成したチームごとの採点結果表 

のような感じで、入れ子になっています。

以上が審判用アプリケーションのフローになります。

選手・観客用アプリケーションのフロー

選手.PNG

こちらが選手・観客用アプリケーションのフローに従った構造図です。

ただ、システム概要のアプリケーションで示した通り、選手・観客用アプリケーションは審判用アプリケーションの機能制限版となっています。

先程の審判用アプリケーションと全く同じフローで、ランキングを閲覧することが出来ます。

7. GitHubの紹介

ここまでソースコードや画像を用いてアプリケーションを紹介してきましたが、やはり触ってみないとわからない部分もあると思います。
そこで、今回のアプリケーションをGitHubに公開しました。
全ソースコードを閲覧可能なほか、github.ioを用いているので、実際にアプリケーションを触ってみることが出来ます。
そしてSeniorExカテゴリのみ、体験版として実際にフォームからデータを送信することが出来ます‼️
是非、お試しください!
なお、体験版のデータは毎日0~1時にすべて消去されますのでご注意ください。
(他のカテゴリは大会仕様で、データは送信できないようにしてあります。)

アプリケーションのリンクはこちら👇

SeniorEx体験版のスプレッドシートはこちら👇

ソースコードのリンクはこちら👇

8. さいごに

最後に、今回この得点集計システムを公開した理由を述べて終わりたいと思います。

理由①

本来WROでは紙ベースで採点しており、それをあえてデジタルにした訳ですから、このことによって害を生み出してはいけません。
その上で、ソースコードを含め仕組みを紹介する事は、システムを悪用される危険性がある為あまり良くないと言えます。

それでも今回公開した理由は、もうこの現行システムは使わないからです。

廃止する理由として、GASおよびSpeeadSheetを用いたシステムに限界を迎えているという点が挙げられます。

データベースにおいて最も気をつけなければならないのは「データの不整合」、特に複数の処理同士の競合・衝突です。
通常のデータベースであればいわゆる占有ロック・共有ロックというものがあり、他の処理によるデータの読み書きを制限する事が出来ます。

しかし、SpreadSheetにはその機能は(私の知る限り)ありません。
大会当日も複数の処理が同時にセルの書き換えを行なってしまい、度々データの不整合が発生しました。
するとシステムの管理者(つまり私)はその対応に追われる事になり、かえって大会運営に支障をきたすこととなってしまいました。
主にこれが理由で、現行システムの利用を止めようと考えました。

又、廃止するにあたって代わりの環境でシステムを作り直さなければならないわけですが、その方法についての目処が立ったことも後押しになりました。

余談

私事ですが今年、 基本情報技術者試験を受け、それにあたりデータベースやSQLの勉強をしました。
これにより、堅牢なシステムを作るための知識を得ることが出来たので、システムを作り直そうと思い立った訳です。

ちなみに、執筆時点では正式な発表はないものの、スコアレポートの結果から恐らく合格していると思われます😊

【2022/12/28 追記】
無事、合格しておりました😁

理由②

もう一つ大きな理由があります。
それは皆さんにこのシステムを利用して欲しいからです。

WROは全国で予選会を開催しています。
その中には、我々のように多くの参加者がおり、運営に苦労されている予選会主催者様もおられるかと思います。
その方々の助けになれば良いと考えております。

尚、実際に使用するためには、システムを自分で構成する必要があり、かつFormを次年度仕様にしなければなりません。
又、上述の問題点を解決することも求められます。
ですので、実際に導入するには様々な壁がありなかなか難しいですが、少しでもお役に立てれば幸いです。

以上、長文に渡りお付き合いいただきありがとうございました。

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?