編集履歴一覧に戻る
kasysのアイコン画像

kasys が 2024年10月30日14時21分50秒 に編集

コメント無し

本文の変更

## 概要 木苺式クイズ早押し機はRaspberry PI Pico Wを使用した10入力クイズ用早押し検知装置です。Raspberry PI Picoシリーズの特徴的な機能であるPIO(Programmable I/O)を使用することで、最大理論検知性能が24nsと非常に高速にも関わらず、低コストで作成可能になっています。 ## 製作動機 Raspberry Pi PicoシリーズにはPIOという非常に面白い機能が搭載されています。PIOは、通常のGPIOの制御を超えて、ハードウェアのように1クロック単位でタイミングを制御することができるプログラマブルな入出力ブロックです。この機能により、通常のマイコンでは難しい複雑かつ高速な入出力操作を実現できます。これにより、ユーザーは独自のシリアル通信プロトコルやパルス幅測定など、通常のマイコンでは難しい複雑な入出力操作を実現できます。特に、タイミングの精度が要求されるタスクや複数のピンの協調動作が必要なシチュエーションにおいて、その力を発揮します。 私は大学の卒業研究でタイミング検出(Time of Flight: ToF)に関する研究を行っていたため、PIOの持つ高精度なタイミング制御機能に強く興味を持ち、活用してみたいと考えました。 ところで、早押しクイズをご存知でしょうか?早押しクイズは、複数の参加者が問題に対していち早くボタンを押し、回答権を得る競技です。そのため、どの参加者が最初にボタンを押したかを正確に判定することが求められます。この判定精度がクイズの公平性を保つ重要な要素となります。PIOは、複数の入力を同時に監視しながら、どの入力が最初にトリガーされたかを非常に短い遅延で判断することができます。この優れたリアルタイム性能が、早押しクイズのような用途に非常に適しています。 そこで、普段は早押しクイズをする機会はありませんが、PIOの特性を活かしてどこまで高性能かつ低コストな早押しクイズ判定機を制作できるかに挑戦しました。 ## 従来の早押し機 一般に有名な早押し機としては「早稲田式クイズ早押し機」があります。これは、クイズ系YouTuberであるQuizKnockが使用していることでも有名です。入力端子も多く、非常に高性能とされていますが、具体的な検知精度に関する情報は公式には公表されていません。ただし、使用者のレビューによると、非常に速いレスポンスが得られることから、実際のクイズ大会での利用にも十分耐える性能があるとされています。しかし、1セットあたり8万円程度らしく非常に高価です。 検索したところ他の早押し機などもありますが、一般販売があるものは大抵数万円するため高価です。また、自作している人も少なくありませんが、多くの場合は低速な通常のGPIOを使用していたり、リレーを組み合わせたものが多く、これらはどうしても検知精度が数十μs の範囲に留まることが多いと考えられます。そのため、ns オーダーの精度を実現しているものはほとんど見つかりませんでした。 ## 設計(システム構成) ![簡易なシステム構成図](https://camo.elchika.com/c2d4ad409774e874d47d49648e7379e0de868e79/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f65353662643033332d313833612d343966662d396563322d6264356664336262346432382f61636466376164322d303961352d343435382d626339342d373038383663643435333137/) 木苺式クイズ早押し機の早押し判定機は各GPIOにボタンを繋ぐだけの非常に簡単な構成になっています。また、早押しの結果やボタンのリセットなどはWeb Bluetooth API(BLE)を経由してパソコンやタブレットなどの端末から行います。これにより、早押し判定機には従来の自作早押し機のようなアナログ検知回路(リレーなど)やオーディオ出力機能、LED表示機能などが不要となるため、非常に低コストに抑えられます。ボタンとの接続には3.5mm4極ミニジャックを使用しています。これは、間違えてオーディオ機器などの別用途の機器を接続しても問題ないピンアサインにするためです。 ## 早押しボタンの制作 早押しボタンは格安なモーメンタリスイッチを使用して作りました。構造は極めて単純でスイッチにケーブルとコネクタをはんだ付けして3Dプリンタ製のケースに入れただけです。色々なカラーの早押しボタンを計9個作成しました。 ![早押しボタン](https://camo.elchika.com/2e5ac5f9e975965db1cf3a8201bc5ef04eb524ef/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f65353662643033332d313833612d343966662d396563322d6264356664336262346432382f33353631333438612d613735342d343236342d396665382d383931383930653665633133/) ![早押しボタン(裏面)](https://camo.elchika.com/9076e05610ed5b36677031cb46440f1cf35ec3fe/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f65353662643033332d313833612d343966662d396563322d6264356664336262346432382f32613864336532322d313662642d343464612d383636332d343333653630373333313232/) ![色々なカラーの早押しボタン](https://camo.elchika.com/afbb9f52cc677fb190107bd6b2be2ad69c7d433f/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f65353662643033332d313833612d343966662d396563322d6264356664336262346432382f35343936656534392d623061352d343761372d396332302d373730633230646264313166/) ## 早押し判定機の制作 早押し判定機のハードウェアもシンプルで、ユニバーサル基板にRaspberry Pi Pico Wと3.5mm4極ミニジャックコネクタを実装しただけの構造です。GPIO0~GPIO9と10個のコネクタがそれぞれ接続されています。また、タイミング検出速度向上のために250MHzにオーバークロックすることにしました。それを考慮して、気休めですがヒートシンクも装着しました。GPIOは内蔵プルアップ抵抗を有効にしてあります。 ![早押し判定機](https://camo.elchika.com/a4913cf6a263beeda771851f27123a5107c4e22d/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f65353662643033332d313833612d343966662d396563322d6264356664336262346432382f36633739623135352d666264342d343339662d393831342d333731386134353363383435/) ![裏面](https://camo.elchika.com/f6e565b04cd7834f6b0f01ea8a5abd5fedf441d0/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f65353662643033332d313833612d343966662d396563322d6264356664336262346432382f37636430323137632d323465332d343531352d613063362d336532383062316665323734/) ## タイミング検出の流れ タイミング検出の流れを説明します。まずPIOを使用してGPIOから使用するすべてのピン入力情報を一気に取得します。その後1ループ前のピン入力情報と比較し、差があればPIOの深さ8のRX FIFO(PIOの出力用のメモリのようなもの)に入力情報を格納します。

-

==**理論検出性能** PIOの一連の動作は6クロック(24ns)で完了します。そのため、早押しの時間差の**理論最検出性能は24ns**となります。また、ピン入力は同時に最大32まで検出可能なので、コネクタを取り付ければ更に入力数を拡張可能です(プログラムの修正は必要ですが)。==

+

==**理論最高検出性能** PIOの一連の動作は6クロック(24ns)で完了します。そのため、連続して計測可能な早押しの時間差の**理論最検出性能は24ns**となります。また、ピン入力は同時に最大32まで検出可能なので、コネクタを取り付ければ更に入力数を拡張可能です(プログラムの修正は必要ですが)。==

-

その後、FIFOに入った全入力データはDMA(Direct Memory Access)によってメモリ内に順次転送されます。メモリ内のデータをメインプログラムがループごとにチェックし、入力を検知した時点で順番とタイムスタンプ(マイクロ秒)を記録します。、並行してデータをBLE経由で送信できるように待ち受けます。

+

その後、FIFOに入った全入力データはDMA(Direct Memory Access)によってメモリ内に順次転送されます。メモリ内のデータをメインプログラムがループごとにチェックし、入力を検知した時点で順番とタイムスタンプ(マイクロ秒)を記録します。現実的にほぼありえせんが、24nsの間に同時に押されて同着となっ場合は乱数を使って決定します。また、並行してデータをBLE経由で送信できるように待ち受けます。

結果表示機のブラウザ上で動くJavaScriptプログラムはWeb Bluetooth API経由で250msごとに現在の早押し順位情報とタイムスタンプを取得しています。そして、取得したデータに変動があれば画面に順位やタイムスタンプを表示するようになっています。 ![タイミングチャート](https://camo.elchika.com/f3cc5b120c87cab8921f7990911df8dd46b5d2b8/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f65353662643033332d313833612d343966662d396563322d6264356664336262346432382f39313565393633362d343637642d343835622d393633662d306334396338313866653466/) ## 検証

+

実際にどのくらいの時間差が検出できているかを検出するために、別のRaspberry Pi PicoのPIOを使用して時間差付きのパルスを出力してテストを行いました。所持しているオシロスコープの最大分解能が20nsなので、とりあえず20nsの時間差パルスを以下のMicroPythonコードで出力して計測しました。 ```python import rp2 import machine # クロックの設定 FREQ = int(100_000_000) # ns換算で20ns machine.freq(FREQ) @rp2.asm_pio(set_init=(rp2.PIO.OUT_HIGH, rp2.PIO.OUT_HIGH), out_init=(rp2.PIO.OUT_HIGH, rp2.PIO.OUT_HIGH)) def button_simulator(): set(pins, 0b11) # 全ボタンを離した状態(HIGH) nop() [5] # 遅延 set(pins, 0b10) # ボタン1 (GPIO0) を押す set(pins, 0b00) # ボタン2も離す(GPIO1) nop() [12] # 遅延 set(pins, 0b11) # 全ボタンを離した状態(HIGH) nop() [18] #遅延 wrap() # ループ(オシロで計測する時用) # 以下は1ショットで実行する場合 # jmp("edge1") # XとYを比較し、異なっていればエッジ検出と判断して"edge"ラベルにジャンプ # label("edge1") # nop() [1] # jmp("edge1") # 再び開始に戻る # PIOの初期化 sm = rp2.StateMachine(0, button_simulator, freq=int(FREQ), set_base=machine.Pin(0)) # ステートマシンをアクティブにしてボタン押下をシミュレート sm.active(1) # ステートマシンが終了するまで待機 while sm.active(): pass ``` 上記のコードでGPIOを出力し、オシロスコープで計測した結果、以下の図のようなオシロスコープの波形を計測できました。オシロスコープの分解能ギリギリなので正確性は若干怪しいですが、設定どおりの結果を得られたのでこのプログラムを使用して10ns、20ns、24ns、40nsの時間差パルスをGPIOに入力しました。 ![](https://camo.elchika.com/57b2516b68921a7186c567522dc3930fefccd3df/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f65353662643033332d313833612d343966662d396563322d6264356664336262346432382f64613166643164622d316130362d343833642d393533312d303530383439633062653334/) ![実験の様子](https://camo.elchika.com/690299113f4aa97d572aa0f5f4d0a81516850854/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f65353662643033332d313833612d343966662d396563322d6264356664336262346432382f38376562326537332d396262322d346461302d623962652d336130623639306561633437/) 実験の結果以下のような結果を得られました。結果として正確に判定できていることがわかります。 | パルス間遅延 [ns] | クロック [MHz] | 結果 | |:---:|:---:|:---:| | 10 | 100 | 同着判定 | | 20 | 50 | 設定順通り | | 24※ | 42 | 設定順通り | | 40 | 25 | 設定順通り | ※正確には23.8ns程度 また、24ns遅延の時のログを以下に示します。このログからは、入力ビットの下3桁が「110」→「100」→「111」となっていることが読み取れます。これは、複数回実行しても正確に入力ビットの判定が行えている事を示しています。したがって、PIOを利用したタイミング検出はうまく行っていると考えられます。 ```bash 00:43:31.077 -> [INFO]Button states have been reset. 00:43:33.919 -> [PRESS]write_pointer=51, read_pointer=48, 00000000000000001111111111111110 00:43:33.919 -> [PRESS]id=1,time=2.868636 00:43:33.919 -> [PRESS]write_pointer=51, read_pointer=49, 00000000000000001111111111111100 00:43:33.919 -> [PRESS]id=2,time=2.869099 00:43:33.919 -> [PRESS]write_pointer=51, read_pointer=50, 00000000000000001111111111111111 00:43:39.154 -> [PRESS]write_pointer=54, read_pointer=51, 00000000000000001111111111111110 00:43:39.154 -> [PRESS]write_pointer=54, read_pointer=52, 00000000000000001111111111111100 00:43:39.186 -> [PRESS]write_pointer=54, read_pointer=53, 00000000000000001111111111111111 00:43:40.492 -> [PRESS]write_pointer=57, read_pointer=54, 00000000000000001111111111111110 00:43:40.492 -> [PRESS]write_pointer=57, read_pointer=55, 00000000000000001111111111111100 00:43:40.492 -> [PRESS]write_pointer=57, read_pointer=56, 00000000000000001111111111111111 00:43:41.983 -> [PRESS]write_pointer=60, read_pointer=57, 00000000000000001111111111111110 00:43:41.983 -> [PRESS]write_pointer=60, read_pointer=58, 00000000000000001111111111111100 00:43:41.983 -> [PRESS]write_pointer=60, read_pointer=59, 00000000000000001111111111111111 ```

## WebUI ## 部品表 制作者の場合は10チャンネルの場合約5000円くらいでした。3Dプリンタがない場合はもう少しかかると思われます。 ### 早押し判定機 | 部品 | 数 | 参考価格 | 備考 | |:---:|:---:| :--: | :--: | | Raspberry PI Pico W | 1 | 1200円程度 | Pico WHも可 | | ヒートシンク | 1 | 数十円程度 | オーバークロック用、無くても可 | | ピンヘッダ(20P)| 2 | 30円程度 | Pico WHの場合不要 | | ピンソケット(20P)| 2 | 50円程度 | | | ユニバーサル基板 | 1 | 数百円 | | | 3.5mm4極ミニジャックコネクタ | 10 | 10個で600円程度 | 理論上はGPIOの数まで拡張可能 | | 簡易ケース | 1 | 不明 | 3Dプリンタで印刷 | ### 早押しボタン | 部品 | 数 | 参考価格 | 備考 | |:---:|:---:| :--: | :--: | | モーメンタリスイッチ | 必要なだけ | 5個で700円程度 | Amazonでの価格 | | 2芯ケーブル | 適量 | 20mで800円 | | | 3.5mm4極ミニジャックプラグ | 10 | 10個で数百円 | | | スイッチ用ケース | 必要なだけ| 不明 | 3Dプリンタで印刷 | ### 結果表示機 | 部品 | 数 | 参考価格 | 備考 | |:---:|:---:| :--: | :--: | | パソコン/タブレット | 1 | ー | ブラウザ(Chrome / Bluefy)が使える端末であれば可 | ## ソースコード ```html:WebUIのコード <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>木苺式クイズ早押し機</title> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css" rel="stylesheet"> <style> body { display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background-color: #f5f5f5; } .container { width: 90vw; height: 90vh; display: grid; grid-template-columns: 33% 33% 33%; grid-template-rows: 15% 70% 15%; gap: 10px; } .header { grid-column: 1 / span 3; background-color: #eee; padding: 10px; text-align: center; font-weight: bold; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1); border-radius: 8px; display: flex; flex-direction: column; justify-content: center; align-items: center; } .header-title { font-size: 2rem; margin-bottom: 10px; } .connection-button { margin-bottom: 10px; } .first-pressed { grid-row: 2 / span 1; grid-column: 2 / span 1; background-color: #e3f2fd; display: flex; flex-direction: column; justify-content: center; align-items: center; font-size: 12rem; color: #0d47a1; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1); border-radius: 8px; } .first-pressed .description { font-size: 1.5rem; margin-bottom: 10px; } .pressed-list { grid-row: 2 / span 1; grid-column: 1 / span 1; background-color: #f1f8e9; padding: 20px; display: flex; flex-direction: column; align-items: center; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1); border-radius: 8px; } .pressed-list .scroll-area { width: 100%; height: calc(100% - 50px); overflow-y: auto; } .pressed-list .description { font-size: 1.5rem; margin-bottom: 10px; text-align: center; } .score-board { grid-row: 2 / span 1; grid-column: 3 / span 1; background-color: #fff3e0; padding: 10px; display: flex; flex-direction: column; align-items: center; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1); border-radius: 8px; } .score-board .scroll-area { width: 100%; height: calc(100% - 50px); overflow-y: auto; } .score-board .description { font-size: 1.5rem; margin-bottom: 10px; text-align: center; } .controller { grid-row: 3 / span 1; grid-column: 1 / span 3; background-color: #ffebee; display: flex; justify-content: space-around; align-items: center; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1); border-radius: 8px; height: 60%; } .controller button { margin: 10px; width: 170px; } .controller button.connected { background-color: #4caf50 !important; color: white; } .controller button.disconnected { background-color: #f44336 !important; color: white; } table { width: 100%; border-collapse: collapse; } table, th, td { border: 1px solid #ccc; } th, td { padding: 5px; text-align: center; } .info-button { position: fixed; bottom: 10px; left: 10px; z-index: 1000; } </style> </head> <body> <div class="container"> <!-- ライセンス表示 --> <div class="info-button"> <button class="btn-floating btn-small blue modal-trigger" data-target="licenseModal"> <i class="material-icons">info</i> </button> </div> <div id="licenseModal" class="modal"> <div class="modal-content"> <h4>ライセンス情報</h4> <hr> <p> <a href="https://github.com/kasys1422/raspberry-pi-quiz-buzzer" target="_blank">raspberry-pi-quiz-buzzer</a> (GPLv3)<br> <a href="https://github.com/kasys1422/raspberry-pi-quiz-buzzer/blob/main/LICENSE" target="_blank">https://github.com/kasys1422/raspberry-pi-quiz-buzzer/blob/main/LICENSE</a><br><br> </p> <p> <strong>サードパーティライセンス</strong> <br> <hr> 1. Material Icons: Apache License Version 2.0<br> <a href="https://www.apache.org/licenses/LICENSE-2.0" target="_blank">https://www.apache.org/licenses/LICENSE-2.0</a><br><br> 2. Materialize CSS: MIT License<br> <a href="https://opensource.org/licenses/MIT" target="_blank">https://opensource.org/licenses/MIT</a><br><br> 3. OtoLogic: CC BY 4.0<br> <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank">https://creativecommons.org/licenses/by/4.0/</a><br><br> 4. arduino-pico: LGPL-2.1 License<br> <a href="https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html" target="_blank">https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html</a> </p> </div> <div class="modal-footer"> <a href="#!" class="modal-close waves-effect waves-green btn-flat">閉じる</a> </div> </div> <!-- タイトルとデバイス接続ボタン --> <div class="header"> <div class="header-title">木苺式クイズ早押し機</div> <button id="connectionButton" class="btn connection-button blue lighten-2 disconnected" onclick="app.toggleConnection()"><i class="material-icons left">bluetooth</i>Bluetooth接続(未接続)</button> </div> <!-- 早押しボタンを押した人を順番に表示するエリア --> <div class="pressed-list"> <div class="description">早押しボタンを押した順番</div> <div class="scroll-area"> <ul id="pressedList" class="collection"> <!-- <li class="collection-item">1️⃣ - 0.453秒</li> <li class="collection-item">3️⃣ - 0.478秒</li> <li class="collection-item">2️⃣ - 0.512秒</li> --> </ul> </div> </div> <!-- 一番早く押した人の番号を表示するエリア --> <div class="first-pressed"> <div class="description">回答者</div> <div id="firstPressed">-</div> </div> <!-- プレイヤーごとの正解数と誤回答数 --> <div class="score-board"> <div class="description">プレイヤーのスコア</div> <div class="scroll-area"> <table id="scoreTable"> <thead> <tr> <th>プレイヤー</th> <th style="color: #ff4336;">正解数 ◯</th> <th style="color: #3647ff;">誤答数 ✕</th> </tr> </thead> <tbody> <tr> <td>プレイヤー1</td> <td>0</td> <td>0</td> </tr> <tr> <td>プレイヤー2</td> <td>0</td> <td>0</td> </tr> <tr> <td>プレイヤー3</td> <td>0</td> <td>0</td> </tr> <tr> <td>プレイヤー4</td> <td>0</td> <td>0</td> </tr> <tr> <td>プレイヤー5</td> <td>0</td> <td>0</td> </tr> <tr> <td>プレイヤー6</td> <td>0</td> <td>0</td> </tr> <tr> <td>プレイヤー7</td> <td>0</td> <td>0</td> </tr> <tr> <td>プレイヤー8</td> <td>0</td> <td>0</td> </tr> <tr> <td>プレイヤー9</td> <td>0</td> <td>0</td> </tr> <tr> <td>プレイヤー10</td> <td>0</td> <td>0</td> </tr> </tbody> </table> </div> </div> <!-- デバイスコントローラ --> <div class="controller"> <button class="btn red lighten-2" onclick="app.resetPressedList()"><i class="material-icons left">refresh</i>リセット</button> <button class="btn blue lighten-2" onclick="app.addPressedEntry()"><i class="material-icons left">play_arrow</i>出題</button> <button class="btn green lighten-2" onclick="app.updateScore('正解')"><i class="material-icons left">check</i>正解</button> <button class="btn red lighten-2" onclick="app.updateScore('不正解')"><i class="material-icons left">close</i>不正解</button> <button class="btn orange lighten-2" onclick="app.resetScores()"><i class="material-icons left">autorenew</i>スコアリセット</button> </div> </div> <script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script> <script> let time_interval = 250; let ranking; let ranking_index = 0; const circledNumbers = { 1: '①', 2: '②', 3: '③', 4: '④', 5: '⑤', 6: '⑥', 7: '⑦', 8: '⑧', 9: '⑨', 10: '⑩', 11: '⑪', 12: '⑫', 13: '⑬', 14: '⑭', 15: '⑮', 16: '⑯', 17: '⑰', 18: '⑱', 19: '⑲', 20: '⑳', 21: '㉑', 22: '㉒', 23: '㉓', 24: '㉔', 25: '㉕', 26: '㉖', 27: '㉗', 28: '㉘', 29: '㉙', 30: '㉚', 31: '㉛', 32: '㉜' }; class AudioPlayer { constructor(src, volume = 1.0, loop = false) { this.audio = new Audio(src); // 音声ファイルのパス this.audio.volume = volume; // 音量の初期値 this.audio.loop = loop; // ループの設定 } // 効果音を再生する play() { this.audio.currentTime = 0; // 再生位置をリセット this.audio.play(); } // 効果音を一時停止する pause() { this.audio.pause(); } // 効果音の再生を停止し、位置をリセット stop() { this.audio.pause(); this.audio.currentTime = 0; } // 音量を設定する setVolume(volume) { this.audio.volume = volume; } // ループの設定を変更する setLoop(loop) { this.audio.loop = loop; } // 効果音が終了したときの処理を設定 onEnded(callback) { this.audio.addEventListener("ended", callback); } } // 効果音 (適宜ソースを用意してください|sampleではOtoLogicの効果音を使用) // 正解音 const correctSound = new AudioPlayer("./sound/Quiz-Buzzer01-1.mp3"); // 不正解音 const incorrectSound = new AudioPlayer("./sound/Quiz-Wrong_Buzzer01-1.mp3"); // 回答音 const answerSound = new AudioPlayer("./sound/Quiz-Buzzer02-1.mp3"); // 出題音 const questionSound = new AudioPlayer("./sound/Quiz-Question03-1.mp3"); class PicoController { constructor() { this.picoDevice = null; this.characteristic = null; this.serviceUuid = "b8e06067-62ad-41ba-9231-206ae80ab551"; this.characteristicUuid = "f897177b-aee8-4767-8ecc-cc694fd5fce0"; this.pollingInterval = null; } async connect() { try { this.picoDevice = await navigator.bluetooth.requestDevice({ acceptAllDevices: true, optionalServices: [this.serviceUuid] }); const server = await this.picoDevice.gatt.connect(); const service = await server.getPrimaryService(this.serviceUuid); this.characteristic = await service.getCharacteristic(this.characteristicUuid); app.updateConnectionUI(true); this.startPolling(); } catch (error) { console.error("接続に失敗しました:", error); } } startPolling() { this.pollingInterval = setInterval(async () => { if (this.characteristic) { await this.getButtonPressHistory(); } }, time_interval); } async getButtonPressHistory() { try { const value = await this.characteristic.readValue(); this.displayButtonPressHistory(value); } catch (error) { console.error("ボタン履歴の取得に失敗:", error); } } displayButtonPressHistory(dataView) { let rankings = []; for (let i = 0; i < dataView.byteLength; i += 5) { const buttonId = dataView.getUint8(i); const timestamp = dataView.getUint32(i + 1, false); rankings.push({ buttonId, timestamp }); } ranking = rankings; app.displayRankings(rankings); } async sendResetCommand() { try { const resetCommand = new Uint8Array([0x52]); await this.characteristic.writeValue(resetCommand); } catch (error) { console.log(`リセットコマンドの送信に失敗:${error}\nリセットコマンドの再送信を行います`,); // エラーを伝搬 throw error; } } } class QuizApp { constructor() { this.picoController = new PicoController(); // 初期時間を設定 this.time_from_reset = new Date().getTime(); } async toggleConnection() { if (document.getElementById('connectionButton').classList.contains('disconnected')) { await this.picoController.connect(); } else { this.updateConnectionUI(false); } } updateConnectionUI(connected) { const button = document.getElementById('connectionButton'); if (connected) { button.classList.remove('disconnected'); button.classList.add('connected'); button.innerHTML = '<i class="material-icons left">bluetooth</i>Bluetooth接続(接続済み)'; } else { button.classList.remove('connected'); button.classList.add('disconnected'); button.innerHTML = '<i class="material-icons left">bluetooth</i>Bluetooth接続(未接続)'; } } // 押されたボタンリストをリセット async resetPressedList() { const pressedList = document.getElementById('pressedList'); pressedList.innerHTML = ''; document.getElementById('firstPressed').innerHTML = '-'; time_interval = 250; ranking_index = 0; this.time_from_reset = new Date().getTime(); try { await this.picoController.sendResetCommand(); // リセットコマンドをPicoに送信 } catch (e) { // 遅延の後に再送信 setTimeout(() => { this.resetPressedList(); }, 100); } } // ボタン押下順のエントリを追加 addPressedEntry() { questionSound.play(); this.resetPressedList(); // 早押しボタンを押した順番をリセット } // スコアを更新する関数(正解・不正解のタイプに応じて) updateScore(type) { // 早押しボタンが押されていない場合は何もしない if (ranking[ranking_index]['buttonId'] == 0) { return; } const scoreTable = document.getElementById('scoreTable').getElementsByTagName('tbody')[0]; const row = scoreTable.rows[Math.floor(document.getElementById('firstPressed').textContent - 1)]; const scoreCell = type === '正解' ? row.cells[1] : row.cells[2]; scoreCell.textContent = parseInt(scoreCell.textContent) + 1; // 効果音 if (type === '正解') { correctSound.play(); } else { incorrectSound.play(); } if (type === '不正解') { ranking_index++; let next_button = ranking[ranking_index]; if (next_button['buttonId'] != 0) { document.getElementById('firstPressed').textContent = next_button['buttonId']; time_interval = 1000; } else { document.getElementById('firstPressed').textContent = '-'; time_interval = 250; } } } // スコアをリセットする resetScores() { const scoreTable = document.getElementById('scoreTable').getElementsByTagName('tbody')[0]; for (let row of scoreTable.rows) { row.cells[1].textContent = '0'; row.cells[2].textContent = '0'; } } displayRankings(rankings) { // ランキング表示を更新 document.getElementById('pressedList').innerHTML = ''; rankings.forEach((rank, index) => { if (rank.buttonId != 0) { const item = document.createElement('li'); item.classList.add('collection-item'); item.textContent = `${circledNumbers[parseInt(rank.buttonId)]} - ${rank.timestamp / 1e6}秒`; document.getElementById('pressedList').appendChild(item); if (index === ranking_index) { if (document.getElementById('firstPressed').textContent != rank.buttonId) { // リセットボタンを押してから0.5秒以上経過していない場合は効果音を再生しない if (new Date().getTime() - this.time_from_reset > 500) { answerSound.play(); } } document.getElementById('firstPressed').textContent = rank.buttonId; time_interval = 1000 } } }); } } const app = new QuizApp(); document.addEventListener('DOMContentLoaded', function () { var elems = document.querySelectorAll('.modal'); M.Modal.init(elems); }); </script> </body> </html> ```