mcpField
C
classroom-multiplayer-host

classroom-multiplayer-host

github.com/lensetekその他011 回インストール1 日前に更新
リポジトリ

classroom-multiplayer-host について

教師向けに、クラスでクイズや教育ゲームをリアルタイム開催するホスティングスキルです。オフラインのローカルネットワークまたは即時 HTTPS トンネルに対応し、得点を記録してエクスポートできます。

主な機能

  1. 01オフライン LAN とホットスポットでのプレイ
  2. 02即時 HTTPS トンネル
  3. 03モバイルブラウザーでの生徒の参加
  4. 04得点と回答の自動記録
  5. 05Excel と JSON へのエクスポート

活用シーン

  1. 01インターネットなしで早押しクイズを実施
  2. 02遠隔地の生徒を授業ゲームに参加させる
  3. 03生徒の得点と設問の難易度を確認
  4. 04ゲーム結果を評価用にエクスポート

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/lensetek/course-agent-skills --skill classroom-multiplayer-host

インストール後、Claude Code で/classroom-multiplayer-hostを入力して読み込まれたか確認します。

詳細

Classroom Multiplayer Host (classroom-multiplayer-host)

物理教室やハイブリッド学習環境で、教育用マルチプレイヤーゲーム(ボードゲームクイズ、早押しトリビア、早押し競争、グループアドベンチャー)を**同時に(リアルタイムで)**提供、配信、進行するための専門エージェントです。

このシステムは、学校や大学のインフラにおける従来の課題を解決します。

  1. 公開ドメインの購入も、クラウド VPS のレンタルも不要です。
  2. 学校のルーターで複雑なポートフォワーディング設定をする必要はありません。
  3. 100%オフラインの LAN/ホットスポットモードに対応(通信量を節約し、レイテンシは 10 ミリ秒未満)。
  4. オンラインの生徒がいる場合や、学校のルーターでAP クライアント分離が有効になっている場合に、ゼロ設定の即時 HTTPS トンネリングに対応します。
  5. 成績の自動記録(リアルタイム評価台帳):すべての操作、回答、ポイントが自動的に記録され、Excel/JSON にエクスポートして成績表/OBE に統合できる成績台帳になります。

🌐 1. デュアルネットワークモード(Dual-Network Modes)

               +-------------------------------------------------+
               |             LAPTOP GURU / DOSEN                 |
               |   - Host Server (Python / Node.js WebSocket)    |
               |   - Layar Proyektor / IFP (/host /projector)    |
               |   - Real-time Assessment & Score Ledger Engine  |
               +-------------------------------------------------+
                                      |
                 +--------------------+--------------------+
                 |                                         |
        [ MODE 1: LAN / OFFLINE ]              [ MODE 2: ZERO-CONFIG TUNNEL ]
        - Wi-Fi Kelas / Hotspot HP             - Cloudflare Quick Tunnel / Untun
        - IP: http://192.168.1.X:8000          - URL: https://xxx.trycloudflare.com
        - 100% Bebas Kuota Internet            - Aman HTTPS, Tanpa Port Forwarding
                 |                                         |
                 +--------------------+--------------------+
                                      |
                        +---------------------------+
                        |  SMARTPHONE / LAPTOP SISWA|
                        |   Scan QR Code / Akses Web|
                        |   Mobile-View First Client|
                        |   Input Nama & NIS / NIM  |
                        |   Tombol Jawaban & Buzzer |
                        +---------------------------+
モード 1:純粋な LAN/ホットスポットモード(100%オフライン&高速)
  • 使用する場面:ローカル Wi-Fi がある教室、または教師がスマートフォンでパーソナルホットスポットを有効にする場合。
  • メリット:
    • インターネット通信量を一切消費しません(インターネット帯域幅ゼロ)。
    • レイテンシはほぼゼロ(10 ミリ秒未満)で、早押しクイズ(ブザー競争)で非常に公平です。
    • 生徒は同じホットスポットの Wi-Fi に接続し、プロジェクター画面に表示された QR コードをスキャンするだけです。
  • IP の自動検出:サーバーがアクティブなネットワークカード(192.168.x.x または 172.x.x.x)を自動検出し、ASCII QR コードをターミナルとプロジェクター画面に直接表示します。
モード 2:ゼロ設定の即時 HTTPS トンネリング(制限なし)
  • 使用する場面:
    • 学校の Wi-Fi でクライアント AP 分離機能が有効になっている場合(ローカル IP 経由でスマートフォン同士がデータを送受信できないようにするルーターのセキュリティ機能)。
    • 自宅から学習する生徒がいるハイブリッド授業(リモート学習)。
  • アカウント/ドメイン設定不要のソリューション:
    • Cloudflare Quick Tunnels(npx untun tunnel --port 8000 または cloudflared tunnel --url http://localhost:8000)または LocalTunnel(npx localtunnel --port 8000)を使用します。
    • 2 秒で安全なリンクを即時生成します:https://learn-xyz.trycloudflare.com。
    • アカウント登録、クレジットカード、DNS 設定は不要で、生徒は各自のスマートフォンから QR コードをスキャンするだけです。

📱 2. 生徒用クライアントの設計(モバイル表示優先)

生徒用ゲームクライアントは、モバイル表示優先の理念に基づいて設計されています。

  1. インストール不要:生徒は Play Store や App Store からアプリをダウンロードする必要はありません。Android、iOS、Chromebook、ノートパソコン上の Chrome、Safari、Samsung Internet、Edge でスムーズに動作します。
  2. すばやく利用開始:
    • 氏名を入力します。
    • **生徒/学生番号(NIS/NIM)**を入力します(成績の自動集計に不可欠です)。
    • チーム戦の場合はチーム/グループを選択するか、カラフルなアバターを選びます。
  3. レスポンシブで使いやすいインターフェース:
    • 回答ボタンは大きく(押し間違いを防ぐ設計)、緊張しているときの誤操作を最小限に抑えます。
    • ブザーボタンは振動応答に対応しています(対応している場合は navigator.vibrate による触覚フィードバック)。
    • 接続状態をリアルタイムで表示します(接続中は緑色のアイコン、信号が途切れると自動で警告)。
    • 即時にフィードバックを表示します:正解時の紙吹雪アニメーション、不正解時の軽い振動、獲得ポイントを表示します。

📊 3. 学業成績台帳と自動集計(成績台帳)

生徒がボタンを押すたび、またはボードゲームのラウンドを終えるたびに、サーバーが高精度の評価トランザクションログを記録します。

サーバーが記録するデータ:
{
  "sessionId": "GAME-20261027-TKRO-01",
  "gameTitle": "Ular Tangga Pemeliharaan Mesin Otomotif",
  "timestamp": "2026-10-27T10:15:32.410Z",
  "student": {
    "nis": "20241088",
    "name": "Budi Pratama",
    "team": "Tim Piston Alpha"
  },
  "questionId": "Q04",
  "topic": "Sistem Pengapian CDI",
  "cpmkKd": "KD 3.4 / CPMK-02",
  "studentAnswer": "C",
  "correctAnswer": "C",
  "isCorrect": true,
  "responseTimeMs": 3420,
  "pointsEarned": 100,
  "currentStreak": 3
}
成績表のエクスポート形式:
  1. 生徒の成績集計(Rekap_Nilai_Game_[Judul]_[Tanggal].xlsx):
    • 列:順位、NIS/NIM、生徒名、チーム、合計スコア、正解数、不正解数、正答率(%)、平均解答速度(秒)、換算成績(100点満点/A~E評価)。
  2. 設問分析(Item Discrimination & Difficulty):
    • 各設問番号について、正解した生徒の割合。
    • 授業終了前に教師/講師が評価できるよう、最も難しい設問(most missed concept)を特定します。
  3. システム連携用ペイロード:
    • student-submission-assessor と obe-excel-dashboard-builder のスキルに直接インポートでき、CPMK/CPLの達成度ポートフォリオを算出するためのJSONファイルです。

💻 4. すぐに使えるスタンドアロンサーバーテンプレート

以下は、教師のノートPCで直接実行できる軽量サーバーの実装です。Python または Node.js を使用できます。

オプションA:スタンドアロンPythonサーバー(classroom_server.py)

標準のPythonと軽量ライブラリ(pip install flask flask-socketio openpyxl netifaces)のみをインストールすれば使用できます。

"""
Classroom Multiplayer Game Host & Real-Time Assessment Ledger
Jalankan: python classroom_server.py
"""
import socket
import datetime
from flask import Flask, render_template_string, request, jsonify
from flask_socketio import SocketIO, emit, join_room
import openpyxl

app = Flask(__name__)
app.config['SECRET_KEY'] = 'classroom-secret-key-safe-local'
socketio = SocketIO(app, cors_allowed_origins="*")

# In-memory session state
session_data = {
    "title": "Kuis Cerdas Cermat & Board Game",
    "status": "LOBBY", # LOBBY, PLAYING, FINISHED
    "students": {},    # sid -> {name, nis, score, correct_count, answers}
    "logs": []
}

def get_local_ip():
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        s.connect(('10.255.255.255', 1))
        IP = s.getsockname()[0]
    except Exception:
        IP = '127.0.0.1'
    finally:
        s.close()
    return IP

@app.route('/')
def student_client():
    # Menampilkan interface kuis Mobile-First untuk siswa
    return render_template_string("""
    <!DOCTYPE html>
    <html lang="id">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
      <title>Kuis Kelas Interaktif</title>
      <script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
      <style>
        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8fafc; margin: 0; padding: 16px; color: #1e293b; }
        .card { background: white; border-radius: 16px; padding: 20px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); max-width: 420px; margin: 0 auto; }
        h1 { font-size: 20px; text-align: center; color: #0284c7; margin-top: 0; }
        input, button { width: 100%; box-sizing: border-box; padding: 14px; margin-bottom: 12px; border-radius: 10px; font-size: 16px; border: 1px solid #cbd5e1; }
        button { background: #0284c7; color: white; border: none; font-weight: bold; cursor: pointer; }
        .grid-answers { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 15px; }
        .btn-opt { background: #e0f2fe; color: #0369a1; border: 2px solid #bae6fd; font-size: 18px; min-height: 80px; }
        .btn-opt:active { background: #0284c7; color: white; }
        .badge { display: inline-block; padding: 4px 10px; border-radius: 99px; background: #e2e8f0; font-size: 12px; font-weight: bold; }
      </style>
    </head>
    <body>
      <div class="card" id="loginCard">
        <h1>🎮 Masuk Permainan Kelas</h1>
        <input type="text" id="nis" placeholder="Nomor Induk Siswa (NIS/NIM)" required>
        <input type="text" id="name" placeholder="Nama Lengkap Siswa" required>
        <button onclick="joinGame()">Mulai Bermain</button>
      </div>

      <div class="card" id="gameCard" style="display:none;">
        <div style="display:flex; justify-content:space-between; align-items:center;">
          <span id="playerInfo" class="badge">Siswa</span>
          <span id="scoreBadge" class="badge" style="background:#fef08a; color:#854d0e;">Skor: 0</span>
        </div>
        <div id="waitMsg" style="text-align:center; padding: 40px 0;">
          <p style="font-size:18px; color:#64748b;">Menunggu guru memulai pertanyaan...</p>
        </div>
        <div id="quizArea" style="display:none;">
          <h2 id="qText" style="font-size:16px;">Pertanyaan</h2>
          <div class="grid-answers">
            <button class="btn-opt" onclick="sendAnswer('A')">A</button>
            <button class="btn-opt" onclick="sendAnswer('B')">B</button>
            <button class="btn-opt" onclick="sendAnswer('C')">C</button>
            <button class="btn-opt" onclick="sendAnswer('D')">D</button>
          </div>
        </div>
      </div>

      <script>
        const socket = io();
        let studentData = {};

        function joinGame() {
          const nis = document.getElementById('nis').value.trim();
          const name = document.getElementById('name').value.trim();
          if(!nis || !name) return alert('Silakan isi NIS dan Nama!');
          studentData = { nis, name };
          socket.emit('student_join', studentData);
          document.getElementById('loginCard').style.display = 'none';
          document.getElementById('gameCard').style.display = 'block';
          document.getElementById('playerInfo').innerText = name + ' (' + nis + ')';
        }

        socket.on('new_question', (data) => {
          document.getElementById('waitMsg').style.display = 'none';
          document.getElementById('quizArea').style.display = 'block';
          document.getElementById('qText').innerText = data.question;
        });

        function sendAnswer(opt) {
          socket.emit('submit_answer', { ...studentData, answer: opt });
          document.getElementById('quizArea').style.display = 'none';
          document.getElementById('waitMsg').style.display = 'block';
          document.getElementById('waitMsg').innerHTML = '<p style="color:#0284c7; font-weight:bold;">Jawaban ' + opt + ' terkirim! Menunggu hasil...</p>';
        }

        socket.on('update_score', (res) => {
          document.getElementById('scoreBadge').innerText = 'Skor: ' + res.score;
        });
      </script>
    </body>
    </html>
    """)

@app.route('/host')
def host_dashboard():
    # Menampilkan Layar Utama Guru / Proyektor Kelas
    return render_template_string("""
    <!DOCTYPE html>
    <html>
    <head>
      <title>Host Monitor - Layar Proyektor</title>
      <script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
      <style>
        body { font-family: sans-serif; background: #0f172a; color: white; padding: 24px; }
        .header { display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #334155; padding-bottom: 12px; }
        .leaderboard { width: 100%; border-collapse: collapse; margin-top: 20px; }
        .leaderboard th, .leaderboard td { padding: 12px; text-align: left; border-bottom: 1px solid #1e293b; }
        .leaderboard th { background: #1e293b; color: #38bdf8; }
        .btn { padding: 10px 20px; font-weight: bold; background: #0284c7; color: white; border: none; border-radius: 8px; cursor: pointer; }
      </style>
    </head>
    <body>
      <div class="header">
        <h1>📊 Layar Proyektor & Monitor Guru</h1>
        <div>
          <button class="btn" onclick="startQuestion()">Kirim Soal Berikutnya</button>
          <a href="/export" class="btn" style="background:#16a34a; text-decoration:none;">📥 Download Rekap Nilai Excel</a>
        </div>
      </div>
      <h3>Peserta Terdaftar: <span id="count">0</span> Siswa</h3>
      <table class="leaderboard" id="tbRank">
        <thead>
          <tr>
            <th>Peringkat</th>
            <th>NIS/NIM</th>
            <th>Nama Siswa</th>
            <th>Skor</th>
            <th>Status</th>
          </tr>
        </thead>
        <tbody id="tbodyRank"></tbody>
      </table>
      <script>
        const socket = io();
        socket.on('roster_update', (data) => {
          document.getElementById('count').innerText = data.students.length;
          const tbody = document.getElementById('tbodyRank');
          tbody.innerHTML = '';
          data.students.sort((a,b) => b.score - a.score).forEach((st, idx) => {
            tbody.innerHTML += `<tr>
              <td>#${idx+1}</td>
              <td>${st.nis}</td>
              <td>${st.name}</td>
              <td><b>${st.score}</b></td>
              <td>Aktif</td>
            </tr>`;
          });
        });

        function startQuestion() {
          const qText = prompt("Ketik teks pertanyaan atau klik OK untuk soal default:", "Apa fungsi utama torak (piston) pada motor bakar?");
          if(qText) {
            socket.emit('host_broadcast_question', { question: qText, correct: 'A' });
          }
        }
      </script>
    </body>
    </html>
    """)

@app.route('/export')
def export_excel():
    # Membuat file Excel rekap nilai otomatis
    wb = openpyxl.Workbook()
    ws = wb.active
    ws.title = "Rekap Nilai Kuis"
    ws.append(["Peringkat", "NIS/NIM", "Nama Lengkap", "Total Skor", "Akurasi Jawaban", "Status Kelulusan"])
    
    sorted_students = sorted(session_data["students"].values(), key=lambda x: x["score"], reverse=True)
    for idx, st in enumerate(sorted_students, start=1):
        status = "Tuntas" if st["score"] >= 75 else "Remedial"
        ws.append([idx, st["nis"], st["name"], st["score"], f"{st.get('correct', 0)} Benar", status])
    
    filename = f"Rekap_Nilai_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
    wb.save(filename)
    return f"File rekap nilai tersimpan di laptop guru: {filename}"

@socketio.on('student_join')
def handle_join(data):
    session_data["students"][request.sid] = {
        "nis": data["nis"],
        "name": data["name"],
        "score": 0,
        "correct": 0
    }
    emit_roster()

@socketio.on('submit_answer')
def handle_answer(data):
    st = session_data["students"].get(request.sid)
    if st:
        # Penilaian instan: Poin 100 untuk jawaban benar
        st["score"] += 100
        st["correct"] += 1
        emit('update_score', {"score": st["score"]})
        emit_roster()

@socketio.on('host_broadcast_question')
def handle_broadcast(data):
    emit('new_question', data, broadcast=True)

def emit_roster():
    socketio.emit('roster_update', {
        "students": list(session_data["students"].values())
    })

if __name__ == '__main__':
    local_ip = get_local_ip()
    print("=" * 60)
    print("🚀 CLASSROOM MULTIPLAYER GAME HOST AKTIF!")
    print(f"👉 Akses Siswa (LAN): http://{local_ip}:8000")
    print(f"👉 Akses Guru (Proyektor): http://localhost:8000/host")
    print("=" * 60)
    print("💡 Ingin Akses Online Bebas Kuota/Sekolah AP-Isolation?")
    print(f"   Buka terminal baru lalu jalankan:")
    print(f"   npx untun tunnel --port 8000")
    print("=" * 60)
    socketio.run(app, host='0.0.0.0', port=8000)

⚡ 5. アカウントなしでトンネリングをすばやく実行するコマンド

学校のWi-Fiが隔離されている場合や、生徒が自宅から学習している場合に、クイズを生徒と共有する方法です。

オプション1:Untun(Cloudflare Quick Tunnels - 追加ソフトウェアのインストール不要)

ターミナルで次のコマンドを実行します。

npx untun tunnel --port 8000

ターミナルに公開URL https://xxx-xxx.trycloudflare.com が表示されます。生徒はSSL証明書の警告なしで、すぐにアクセスできます。

オプション2:Localtunnel
npx localtunnel --port 8000

📋 6. 認証情報のセキュリティと評価倫理のチェックリスト

  1. 認証情報のセキュリティ:このクイズサーバーは、クラウドデータベースの認証情報、秘密のAPIトークン、クライアント側のパスワードを必要としない設計です。生徒のログデータはすべて、教師のサーバーのメモリにローカル保存されます。
  2. 生徒データの機密性:成績データが公開の第三者にアップロードされることはありません。.xlsx ファイルと .json ファイルは、教師のノートPCのローカルストレージに直接ダウンロードされます。
  3. 評価の完全性:解答キーは教師のサーバー側(/host)にのみ保存されるため、生徒がブラウザーの Inspect Element から解答を覗き見ることはできません。

🤝 7. Course-Agent-Skillsエコシステムとの連携

  • board-game-quiz-maker で作成したゲームから生成されます。
  • 成績と回答ログは、student-submission-assessor が分析できる形式で直接エクスポートされます。
  • スコアの累計集計は、obe-excel-dashboard-builder を通じてOBE達成度ポートフォリオに取り込まれます。

コメント