关于 classroom-multiplayer-host
面向教师的课堂游戏托管技能,可实时运行多人测验和教育游戏。支持离线局域网或即时 HTTPS 隧道,并记录成绩以供导出。
主要功能
- 01离线局域网与热点游戏
- 02即时 HTTPS 隧道
- 03基于移动浏览器的学生游戏
- 04自动记录成绩与作答
- 05导出 Excel 和 JSON
适用场景
- 01无互联网时开展抢答测验
- 02让远程学生参与课堂游戏
- 03查看学生成绩与题目难度
- 04导出游戏结果用于评估
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ lensetek/ course-agent-skills --skill classroom-multiplayer-host安装后在 Claude Code 中输入/classroom-multiplayer-host确认已加载。
详细介绍
课堂多人游戏主持端(classroom-multiplayer-host)
专用 Agent,用于在实体课堂或混合式学习环境中,**同步(实时)**呈现、分发并引导教育类多人游戏(棋盘游戏问答、知识竞赛、抢答比赛和团队冒险)。
该系统解决了学校/校园基础设施的常见难题:
- 无需购买公共域名或租用云 VPS。
- 无需在学校路由器上进行复杂的端口转发配置。
- 支持 100% 离线局域网/热点模式(节省流量,延迟 < 10 ms)。
- 支持零配置即时 HTTPS 隧道,适用于有学生在线学习或学校路由器启用了 AP 客户端隔离的情况。
- 自动记录成绩(实时评估账本):每次交互、回答和得分都会自动保存为成绩册,可导出为 Excel/JSON,以便整合到成绩单/OBE 中。
🌐 1. 双网络架构(双网络模式)
+-------------------------------------------------+
| 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:纯局域网/热点模式(100% 离线且快速)
- 适用场景:在使用本地 Wi-Fi 的教室,或教师在智能手机上开启个人热点时。
- 优势:
- 完全不消耗互联网流量(零互联网带宽)。
- 即时低延迟(<10 毫秒),非常适合抢答竞赛(抢答器比赛)。
- 学生只需连接到同一 Wi-Fi 热点,并扫描投影屏幕上的二维码。
- 自动检测 IP:服务自动检测活动网卡(
192.168.x.x或172.x.x.x),并直接在终端和投影屏幕上打印 ASCII 二维码。
模式 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,学生只需用各自的智能手机扫描二维码即可。
- 使用 Cloudflare Quick Tunnels(
📱 2. 学生客户端设计(移动端优先)
面向学生的游戏客户端遵循移动端优先的设计理念:
- 零安装:学生无需从 Play Store 或 App Store 下载应用。在 Android、iOS、Chromebook 和笔记本电脑上的 Chrome、Safari、Samsung Internet、Edge 中均可流畅运行。
- 快速入门:
- 输入全名。
- 输入学生/大学生学号(NIS/NIM)(对自动汇总成绩至关重要)。
- 选择队伍/小组(团队游戏时)或彩色头像。
- 响应式人体工学界面:
- 答题按钮尺寸较大(方便手指操作),尽量避免紧张时误触。
- 抢答器按钮支持振动反馈(如果支持
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
}
成绩册导出格式:
- 学生成绩汇总(
Rekap_Nilai_Game_[Judul]_[Tanggal].xlsx):- 列:排名、NIS/NIM、学生姓名、队伍、总分、正确答案数、错误答案数、准确率(%)、平均答题速度(秒)、换算成绩(百分制 / A-E 等级)。
- 试题分析(区分度与难度):
- 每道题的答对学生百分比。
- 识别最难的题目(最常答错的概念),供教师/讲师在课程结束前评估。
- 系统集成载荷:
- 可直接导入
student-submission-assessor和obe-excel-dashboard-builder技能的即用型 JSON 文件,用于计算 CPMK/CPL 达成情况的作品集。
- 可直接导入
💻 4. 即用型独立服务器模板
以下是轻量服务器的实现,可由教师直接在笔记本电脑上使用 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. 凭据安全与评估规范检查清单
- 凭据安全:此测验服务器无需云数据库凭据、机密 API 令牌或客户端密码。所有学生日志数据都保存在教师服务器的内存中。
- 学生数据保密:成绩数据不会上传至公共第三方;
.xlsx和.json文件会直接下载到教师笔记本电脑的本地存储介质。 - 评估完整性:答案仅存储在教师服务器端(
/host),因此学生无法通过浏览器的检查元素查看答案。
🤝 7. 与 Course-Agent-Skills 生态系统集成
- 由
board-game-quiz-maker创建的游戏生成。 - 成绩和回答日志会直接导出,供
student-submission-assessor分析。 - 累计分数汇总会通过
obe-excel-dashboard-builder纳入 OBE 达成情况作品集。
评论
支持 Markdown