Files
calx/index.html
YANG JIANKUAN e42e2202b1 feat: 双人抢答模式 + 识别优化 + PC/Pad 布局重设计
- 识别:按题动态 grammar(前缀锁定因数)、只输出中文数字、发音兼容别名(n/l 不分、四十↔十四混淆重录)
- 新增双人抢答:键盘/蓝牙手柄绑定抢答、A 秒硬截止扣分、实时计分与冠军结算,支持顺序/随机出题
- 全局口诀范围设置(几开头~几开头,localStorage 持久化)
- 修复退出比赛残留定时器弹结果卡的 bug(epoch 代际守卫);计时条改 rAF 逐帧驱动
- 新增 eval.js 端到端识别回归评测(135 例 100%)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:24:28 +08:00

1077 lines
60 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>🦉 乘法口诀大闯关</title>
<style>
/* ================= 多邻国风 · PC/Pad 布局 =================
设计基调:高饱和主色 + 厚底 3D 按钮 + 大圆角卡片 + 弹跳动效
仅面向 PC / Pad≥760px不做手机端适配 */
:root{
--green:#58cc02; --green-d:#46a302; --red:#ff4b4b; --red-d:#d63535;
--blue:#1cb0f6; --blue-d:#1899d6; --yellow:#ffc800; --yellow-d:#d9a900;
--purple:#ce82ff; --purple-d:#a568d6;
--ink:#3c3c3c; --gray:#e5e5e5; --gray-t:#afafaf;
--bg:#f4f7fb; --card:#ffffff; --line:#e4e8ee;
--shadow-card:0 10px 28px rgba(60,80,120,.08);
}
*{ box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
html,body{ height:100%; margin:0; }
body{
font-family:"SF Pro Rounded","PingFang SC","Microsoft YaHei",-apple-system,"Segoe UI",system-ui,sans-serif;
color:var(--ink);
background:
radial-gradient(720px 460px at 6% -8%, rgba(88,204,2,.12), transparent 62%),
radial-gradient(820px 540px at 104% 12%, rgba(28,176,246,.12), transparent 62%),
radial-gradient(600px 420px at 50% 112%, rgba(255,200,0,.10), transparent 60%),
var(--bg);
min-height:100%; min-width:760px;
display:flex; align-items:center; justify-content:center;
padding:28px 32px; overflow-x:auto; user-select:none;
}
.app{ width:100%; max-width:1040px; }
.card{ margin:0 auto; padding:12px 8px 44px; }
#home{ max-width:960px; }
#game,#end{ max-width:660px; }
#match{ max-width:1020px; }
h1{ text-align:center; margin:12px 0 4px; font-size:46px; font-weight:900; color:var(--green);
letter-spacing:2px; text-shadow:0 2px 0 rgba(70,163,2,.18); }
.subtitle{ text-align:center; margin:0 0 26px; color:var(--gray-t); font-size:19px; font-weight:700; }
.hide{ display:none !important; }
button:focus-visible, input:focus-visible{ outline:3px solid var(--blue); outline-offset:2px; }
.mascot{ font-size:88px; text-align:center; line-height:1; margin:8px 0;
filter:drop-shadow(0 8px 14px rgba(60,80,120,.18)); }
@keyframes bob{ 0%,100%{transform:translateY(0) rotate(-3deg);} 50%{transform:translateY(-14px) rotate(3deg);} }
.bob{ animation:bob 2.4s ease-in-out infinite; }
@keyframes pop{ 0%{transform:scale(.5);} 65%{transform:scale(1.28);} 100%{transform:scale(1);} }
.pop{ animation:pop .45s ease-out; }
@keyframes shake{ 0%,100%{transform:translateX(0);} 20%{transform:translateX(-11px);} 40%{transform:translateX(11px);} 60%{transform:translateX(-8px);} 80%{transform:translateX(8px);} }
.shake{ animation:shake .45s; }
@keyframes rise{ 0%{opacity:0; transform:translateY(26px) scale(.96);} 100%{opacity:1; transform:none;} }
.btn{
border:none; border-radius:18px; font-family:inherit; font-weight:800; letter-spacing:.5px;
cursor:pointer; padding:16px; font-size:19px; color:#fff;
box-shadow:0 5px 0 var(--green-d); transition:transform .08s, box-shadow .08s, filter .12s;
width:100%; margin:10px 0; display:flex; align-items:center; justify-content:center; gap:10px;
}
@media(hover:hover){ .btn:hover{ filter:brightness(1.06); transform:translateY(-2px); } }
.btn:active{ transform:translateY(4px); box-shadow:0 1px 0 var(--green-d); filter:none; }
.btn.big{ font-size:23px; padding:20px; }
.b-green{background:var(--green); box-shadow:0 5px 0 var(--green-d);}
.b-blue{background:var(--blue); box-shadow:0 5px 0 var(--blue-d);}
.b-red{background:var(--red); box-shadow:0 5px 0 var(--red-d);}
.b-yellow{background:var(--yellow); color:#7a5b00; box-shadow:0 5px 0 var(--yellow-d);}
.b-purple{background:var(--purple); box-shadow:0 5px 0 var(--purple-d);}
.b-white{background:#fff; color:var(--gray-t); border:2px solid var(--gray); box-shadow:0 3px 0 var(--gray);}
.btn.small{ font-size:16px; padding:12px 18px; width:auto; margin:0; border-radius:15px; }
/* 首页:三张模式卡横排,错峰入场 */
.modes{ display:grid; grid-template-columns:repeat(3,1fr); gap:20px; margin:6px 0 4px; }
.entry{ flex-direction:column; gap:8px; padding:30px 20px 26px; margin:0; border-radius:24px;
animation:rise .55s cubic-bezier(.2,.9,.3,1.2) backwards; }
.modes .entry:nth-child(1){ animation-delay:.05s; }
.modes .entry:nth-child(2){ animation-delay:.15s; }
.modes .entry:nth-child(3){ animation-delay:.25s; }
@media(hover:hover){ .entry:hover{ transform:translateY(-6px) rotate(-.6deg); } }
.entry .e-emoji{ font-size:52px; line-height:1; filter:drop-shadow(0 4px 6px rgba(0,0,0,.15)); }
.entry .e-title{ font-size:26px; font-weight:900; }
.entry .e-sub{ font-size:14px; font-weight:700; opacity:.92; }
.top{ display:flex; align-items:center; gap:14px; margin:2px 0 18px; }
.exit{ background:none; border:none; color:var(--gray-t); font-size:28px; font-weight:900; cursor:pointer;
line-height:1; padding:2px 6px; border-radius:10px; transition:transform .12s, color .12s; }
@media(hover:hover){ .exit:hover{ color:var(--red); transform:scale(1.15); } }
.bar{ flex:1; height:18px; background:var(--gray); border-radius:999px; overflow:hidden; }
.bar>i{ display:block; height:100%; width:0; background:linear-gradient(180deg,#6fdd1a,var(--green)); border-radius:999px; transition:width .4s; }
.scoretag{ font-weight:900; color:var(--yellow-d); font-size:20px; white-space:nowrap;
background:rgba(255,200,0,.16); border-radius:999px; padding:4px 14px; }
.timer{ height:14px; background:var(--gray); border-radius:999px; overflow:hidden; margin:4px 0 20px; }
.timer>i{ display:block; height:100%; width:100%; background:var(--green); border-radius:999px; }
.problem{ text-align:center; font-size:84px; font-weight:900; margin:16px 0 6px; letter-spacing:3px; }
.problem .q{ color:var(--blue); }
.kou-hint{ text-align:center; font-size:24px; color:var(--gray-t); min-height:30px; margin-bottom:8px; font-weight:800; }
.bubble{
background:var(--card); border:2px solid var(--line); border-radius:18px; padding:16px 20px; text-align:center;
font-size:24px; font-weight:800; min-height:64px; margin:12px 0; display:flex; align-items:center; justify-content:center;
color:var(--ink); box-shadow:var(--shadow-card);
}
.bubble .dim{ color:var(--gray-t); font-weight:600; font-size:18px; }
.mic-wrap{ text-align:center; margin:10px 0; }
.mic{ width:118px; height:118px; border-radius:50%; border:none; cursor:pointer; font-size:54px; color:#fff;
background:var(--green); box-shadow:0 7px 0 var(--green-d); transition:transform .08s, filter .12s; }
@media(hover:hover){ .mic:hover{ filter:brightness(1.06); } }
.mic:active{ transform:translateY(3px); box-shadow:0 3px 0 var(--green-d); }
@keyframes ripple{ 0%{box-shadow:0 7px 0 var(--blue-d), 0 0 0 0 rgba(28,176,246,.5);} 100%{box-shadow:0 7px 0 var(--blue-d), 0 0 0 36px rgba(28,176,246,0);} }
.mic.listening{ animation:ripple 1.1s infinite; background:var(--blue); }
.mic.think{ background:var(--purple); box-shadow:0 7px 0 var(--purple-d); }
.mic-label{ font-size:16px; color:var(--gray-t); font-weight:800; margin-top:10px; }
.pad{ display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin-top:8px; }
.pad button{ font-family:inherit; font-size:28px; font-weight:800; padding:18px 0; border:2px solid var(--gray);
border-radius:16px; background:#fff; color:var(--ink); cursor:pointer; box-shadow:0 3px 0 var(--gray);
transition:transform .06s, filter .1s; }
@media(hover:hover){ .pad button:hover{ filter:brightness(.97); } }
.pad button:active{ transform:translateY(3px); box-shadow:none; }
.pad .go{ background:var(--green); color:#fff; border-color:var(--green-d); box-shadow:0 3px 0 var(--green-d); }
.pad .del{ background:var(--yellow); color:#7a5b00; border-color:var(--yellow-d); box-shadow:0 3px 0 var(--yellow-d); }
.tools{ display:flex; gap:10px; margin-top:18px; justify-content:center; flex-wrap:wrap; }
.set-row{ display:flex; align-items:center; justify-content:space-between; padding:10px 0; font-weight:700; font-size:16px; color:#555; }
.set-row input[type=number]{ width:66px; font:inherit; font-weight:800; text-align:center; border:2px solid var(--gray); border-radius:10px; padding:6px; }
.range-pair{ display:flex; align-items:center; gap:8px; font-weight:800; color:var(--gray-t); }
.switch{ position:relative; width:52px; height:30px; }
.switch input{ display:none; }
.switch i{ position:absolute; inset:0; background:var(--gray); border-radius:999px; transition:.2s; }
.switch i::after{ content:""; position:absolute; top:3px; left:3px; width:24px; height:24px; background:#fff; border-radius:50%; transition:.2s; }
.switch input:checked + i{ background:var(--green); }
.switch input:checked + i::after{ transform:translateX(22px); }
/* 角落齿轮按钮 */
.gear{ position:absolute; top:4px; right:4px; width:48px; height:48px; border:none; background:none;
font-size:26px; color:var(--gray-t); cursor:pointer; opacity:.55; transition:transform .2s, opacity .2s; }
.gear:hover{ opacity:1; transform:rotate(30deg); } .gear:active{ transform:rotate(60deg); }
/* 设置弹窗 */
.modal{ position:fixed; inset:0; background:rgba(24,40,60,.4); z-index:70;
display:flex; align-items:center; justify-content:center; padding:20px; }
.modal-card{ background:#fff; width:100%; max-width:460px; border-radius:24px; padding:20px 24px;
box-shadow:0 24px 70px rgba(0,0,0,.3); animation:mpop .22s ease-out; }
@keyframes mpop{ 0%{transform:scale(.9); opacity:0;} 100%{transform:scale(1); opacity:1;} }
.modal-head{ display:flex; align-items:center; justify-content:space-between; margin-bottom:6px;
font-weight:900; font-size:20px; color:var(--ink); }
.modal-x{ background:none; border:none; font-size:22px; font-weight:900; color:var(--gray-t); cursor:pointer; padding:4px 8px; }
/* 底部结果条(多邻国式反馈条),内容在宽屏下限宽居中 */
.sheet{ position:fixed; left:0; right:0; bottom:0; transform:translateY(120%);
transition:transform .3s cubic-bezier(.2,.9,.3,1.15); z-index:60;
padding:24px 32px calc(24px + env(safe-area-inset-bottom));
border-top-left-radius:26px; border-top-right-radius:26px; box-shadow:0 -8px 30px rgba(0,0,0,.1); }
.sheet.show{ transform:translateY(0); }
.sheet.ok{ background:#d7ffb8; } .sheet.no{ background:#ffdfe0; }
.sheet .head{ display:flex; align-items:center; gap:16px; max-width:720px; margin:0 auto; }
.sheet .icon{ font-size:50px; }
.sheet .s-title{ font-size:27px; font-weight:900; }
.sheet.ok .s-title{ color:var(--green-d); } .sheet.no .s-title{ color:#ea2b2b; }
.sheet .s-sub{ font-size:18px; font-weight:800; margin-top:2px; }
.sheet.ok .s-sub{ color:var(--green-d); } .sheet.no .s-sub{ color:#ea2b2b; }
.sheet .cont{ margin:18px auto 0; display:block; width:100%; max-width:720px; border:none; border-radius:18px;
padding:16px; font-size:19px; font-weight:900; color:#fff; cursor:pointer; transition:transform .08s; }
.sheet.ok .cont{ background:var(--green); box-shadow:0 5px 0 var(--green-d); }
.sheet.no .cont{ background:var(--red); box-shadow:0 5px 0 var(--red-d); }
.sheet .cont:active{ transform:translateY(4px); }
.flash{ position:fixed; inset:0; pointer-events:none; z-index:40; opacity:0; }
.flash.g{ background:radial-gradient(circle,rgba(88,204,2,.28),transparent 70%); animation:fl .6s; }
.flash.r{ background:radial-gradient(circle,rgba(255,75,75,.3),transparent 70%); animation:fl .5s; }
@keyframes fl{ 0%{opacity:0;} 30%{opacity:1;} 100%{opacity:0;} }
.fx{ position:fixed; inset:0; pointer-events:none; overflow:hidden; z-index:50; }
.fx span{ position:absolute; animation:fall 1.5s ease-in forwards; }
@keyframes fall{ 0%{transform:translateY(-12vh) rotate(0); opacity:1;} 100%{transform:translateY(112vh) rotate(560deg); opacity:0;} }
.stars{ font-size:52px; text-align:center; letter-spacing:8px; margin:12px 0; }
.score-big{ font-size:60px; text-align:center; font-weight:900; color:var(--yellow-d); }
.note{ font-size:13.5px; color:var(--gray-t); text-align:center; margin-top:16px; line-height:1.8; font-weight:600; }
/* ===== 抢答模式:左右玩家 + 中间出题区(宽屏三栏) ===== */
.qnum{ flex:1; text-align:center; font-weight:900; color:var(--gray-t); font-size:18px; }
.arena{ display:flex; gap:22px; align-items:stretch; }
.player{ width:172px; flex:none; background:var(--card); border:3px solid var(--line); border-radius:24px;
padding:22px 10px 16px; text-align:center; box-shadow:var(--shadow-card);
display:flex; flex-direction:column; align-items:center; gap:7px; position:relative;
transition:border-color .15s, box-shadow .15s, transform .15s;
animation:rise .5s cubic-bezier(.2,.9,.3,1.2) backwards; }
.arena .player:last-child{ animation-delay:.1s; }
.player .avatar{ font-size:62px; line-height:1.15; filter:drop-shadow(0 4px 6px rgba(0,0,0,.12)); }
.player .pname{ font-size:17px; font-weight:900; color:#555; }
.player .pscore{ font-size:40px; font-weight:900; color:var(--yellow-d); line-height:1.1; }
.player .pkey{ font-size:13px; font-weight:700; color:var(--gray-t); word-break:break-all; min-height:17px; }
.player .bindbtn{ font-family:inherit; font-size:14px; font-weight:800; border:2px solid var(--gray); background:#fff; color:#777;
border-radius:12px; padding:8px 12px; cursor:pointer; margin-top:2px; box-shadow:0 2px 0 var(--gray); transition:filter .1s; }
@media(hover:hover){ .player .bindbtn:hover{ filter:brightness(.96); } }
.player .bindbtn.capturing{ border-color:var(--blue); color:var(--blue); animation:blink .8s infinite; }
@keyframes blink{ 50%{opacity:.35;} }
.player.buzzed{ border-color:var(--yellow); transform:scale(1.08);
box-shadow:0 0 0 5px rgba(255,200,0,.35), 0 10px 26px rgba(255,200,0,.45); }
.player.winner{ border-color:var(--yellow); box-shadow:0 0 0 5px rgba(255,200,0,.4), var(--shadow-card); }
.player.loser{ opacity:.45; }
.buzztag{ position:absolute; top:-15px; left:50%; transform:translateX(-50%); background:var(--yellow); color:#7a5b00;
font-size:14px; font-weight:900; border-radius:999px; padding:4px 12px; white-space:nowrap;
box-shadow:0 3px 0 var(--yellow-d); animation:pop .4s ease-out; z-index:2; }
.mid{ flex:1; min-width:0; display:flex; flex-direction:column; }
#match .problem{ font-size:64px; margin:8px 0 2px; }
#match .bubble{ font-size:21px; min-height:56px; padding:12px 16px; }
#match .mic{ width:96px; height:96px; font-size:44px; }
#match .mascot{ font-size:58px; }
/* 抢答大厅:顺序/随机 分段选择 */
.segwrap{ display:flex; gap:10px; justify-content:center; margin:6px 0 4px; }
.seg{ font-family:inherit; font-size:17px; font-weight:800; padding:12px 22px; cursor:pointer;
border:2px solid var(--gray); border-radius:16px; background:#fff; color:var(--gray-t);
box-shadow:0 3px 0 var(--gray); transition:all .12s; }
.seg.on{ background:var(--blue); border-color:var(--blue-d); color:#fff; box-shadow:0 3px 0 var(--blue-d); }
@media(hover:hover){ .seg:hover{ filter:brightness(1.03); transform:translateY(-1px); } }
.seg:active{ transform:translateY(2px); box-shadow:none; }
</style>
</head>
<body>
<div class="app">
<!-- ===== 首页 ===== -->
<div id="home" class="card" style="position:relative">
<button class="gear" onclick="openSettings()" title="家长设置">⚙️</button>
<div class="mascot bob">🦉</div>
<h1>乘法口诀大闯关</h1>
<p class="subtitle">开口念答案,全程不用手!</p>
<div class="modes">
<button class="btn b-green entry" onclick="startGame('seq')">
<span class="e-emoji">🐢</span><span class="e-title">顺序闯关</span><span class="e-sub">按口诀表顺序背</span></button>
<button class="btn b-blue entry" onclick="startGame('rand')">
<span class="e-emoji">🎲</span><span class="e-title">随机挑战</span><span class="e-sub">打乱抽题,错题多练</span></button>
<button class="btn b-purple entry" onclick="enterMatch()">
<span class="e-emoji">🆚</span><span class="e-title">双人抢答</span><span class="e-sub">按键抢答,比比谁快</span></button>
</div>
<p class="note" id="homeNote"></p>
</div>
<!-- ===== 设置弹窗 ===== -->
<div class="modal hide" id="settingsModal" onclick="if(event.target===this)closeSettings()">
<div class="modal-card">
<div class="modal-head"><span>⚙️ 家长设置</span><button class="modal-x" onclick="closeSettings()"></button></div>
<div class="set-row"><span>📖 口诀范围(几开头)</span>
<span class="range-pair"><input type="number" id="cfgRangeMin" min="1" max="9" step="1" value="1" onchange="setRange('min',this.value)">
<input type="number" id="cfgRangeMax" min="1" max="9" step="1" value="9" onchange="setRange('max',this.value)"></span></div>
<div class="set-row"><span>🔊 自动读题</span>
<label class="switch"><input type="checkbox" id="cfgRead" checked onchange="CONFIG.ttsQuestion=this.checked"><i></i></label></div>
<div class="set-row"><span>⏱️ 限时作答</span>
<label class="switch"><input type="checkbox" id="cfgTimer" checked onchange="CONFIG.timerEnabled=this.checked"><i></i></label></div>
<div class="set-row"><span>每题作答秒数</span>
<input type="number" id="cfgSec" min="2" max="30" step="1" value="6" oninput="CONFIG.timeLimitMs=Math.max(2,+this.value||6)*1000"></div>
<div class="set-row"><span>随机模式题数</span>
<input type="number" id="cfgLen" min="5" max="99" step="1" value="20" oninput="CONFIG.sessionLength=Math.max(5,+this.value||20)"></div>
<div class="set-row"><span>「慢」判定秒数</span>
<input type="number" id="cfgSlow" min="1" max="20" step="0.5" value="3" oninput="CONFIG.slowMs=Math.max(0.5,+this.value||3)*1000"></div>
<div class="set-row"><span>抢答等待秒数</span>
<input type="number" id="cfgBuzz" min="2" max="30" step="1" value="5" oninput="CONFIG.matchBuzzMs=Math.max(2,+this.value||5)*1000"></div>
<div class="set-row"><span>抢到后作答秒数</span>
<input type="number" id="cfgAns" min="2" max="30" step="1" value="6" oninput="CONFIG.matchAnswerMs=Math.max(2,+this.value||6)*1000"></div>
<div class="set-row"><span>抢答一局题数</span>
<input type="number" id="cfgMatchLen" min="4" max="50" step="1" value="10" oninput="CONFIG.matchQuestions=Math.max(4,+this.value||10)"></div>
<p class="note" style="margin-top:6px">随机模式里答错或答慢的口诀会更常出现,练熟后自动减少。</p>
</div>
</div>
<!-- ===== 游戏页 ===== -->
<div id="game" class="card hide">
<div class="top">
<button class="exit" onclick="quit()" title="返回"></button>
<div class="bar"><i id="barFill"></i></div>
<span class="scoretag" id="scorePill">⭐0</span>
</div>
<div class="timer" id="timerWrap"><i id="timerBar"></i></div>
<div class="mascot" id="gMascot">🦉</div>
<div class="problem" id="problem"></div>
<div class="kou-hint" id="kouHint"></div>
<div class="bubble" id="heard"><span class="dim">准备好啦~</span></div>
<div class="mic-wrap" id="micWrap">
<button class="mic" id="micBtn" onclick="micTap()">🎤</button>
<div class="mic-label" id="micLabel">全自动:听题→说答案</div>
</div>
<div id="padWrap" class="hide">
<div class="bubble"><span id="padShow" class="dim">点数字输入答案</span></div>
<div class="pad">
<button onclick="padKey('1')">1</button><button onclick="padKey('2')">2</button><button onclick="padKey('3')">3</button>
<button onclick="padKey('4')">4</button><button onclick="padKey('5')">5</button><button onclick="padKey('6')">6</button>
<button onclick="padKey('7')">7</button><button onclick="padKey('8')">8</button><button onclick="padKey('9')">9</button>
<button class="del" onclick="padDel()"></button><button onclick="padKey('0')">0</button><button class="go" onclick="padGo()"></button>
</div>
</div>
<div class="tools">
<button class="btn small b-yellow" onclick="speakKou()">🔊 听口诀</button>
<button class="btn small b-purple" id="toggleInput" onclick="toggleInputMode()">⌨️ 用手写</button>
</div>
</div>
<!-- ===== 抢答页(左右玩家 + 中间出题区) ===== -->
<div id="match" class="card hide">
<div class="top">
<button class="exit" onclick="quit()" title="返回"></button>
<span class="qnum" id="mQnum">双人抢答</span>
<span style="width:30px"></span>
</div>
<div class="timer hide" id="mTimerWrap"><i id="mTimerBar"></i></div>
<div class="arena">
<div class="player" id="pL">
<span class="buzztag hide" id="tagL">⚡ 抢到了!</span>
<div class="avatar">🐯</div><div class="pname">左边</div>
<div class="pscore" id="scoreL">0</div>
<div class="pkey" id="keyL"></div>
<button class="bindbtn" id="bindL" onclick="bindCapture('L')">🔧 绑定按键</button>
</div>
<div class="mid">
<div class="mascot" id="mMascot" style="font-size:52px">🦉</div>
<div class="problem" id="mProblem"></div>
<div class="kou-hint" id="mKouHint"></div>
<div class="bubble" id="mHeard"><span class="dim">准备好啦~</span></div>
<div class="mic-wrap hide" id="mMicWrap">
<button class="mic" id="mMic" onclick="micTap()">🎤</button>
<div class="mic-label" id="mMicLabel">快说答案!</div>
</div>
<div id="mPadSlot"></div>
<div id="mLobby">
<div class="segwrap">
<button class="seg" id="mOrderSeq" onclick="setMatchOrder('seq')">🐢 顺序出题</button>
<button class="seg on" id="mOrderRand" onclick="setMatchOrder('rand')">🎲 随机出题</button>
</div>
<button class="btn b-green big" onclick="matchStart()">🚀 开始比赛</button>
<p class="note" style="margin-top:6px">出题后按自己绑定的键抢答,抢到的小朋友开口说答案。<br>答对 +5 分,答错或超时 2 分。支持键盘和蓝牙手柄按键。</p>
</div>
<div id="mEnd" class="hide">
<div class="mascot" id="mCrown">👑</div>
<div class="score-big" id="mEndScore"></div>
<p class="subtitle" id="mEndMsg"></p>
<button class="btn b-green" onclick="matchStart()">🔁 再来一局</button>
<button class="btn b-white" onclick="quit()">🏠 回首页</button>
</div>
</div>
<div class="player" id="pR">
<span class="buzztag hide" id="tagR">⚡ 抢到了!</span>
<div class="avatar">🐰</div><div class="pname">右边</div>
<div class="pscore" id="scoreR">0</div>
<div class="pkey" id="keyR"></div>
<button class="bindbtn" id="bindR" onclick="bindCapture('R')">🔧 绑定按键</button>
</div>
</div>
</div>
<!-- ===== 结算页 ===== -->
<div id="end" class="card hide">
<div class="mascot bob" id="endMascot">🏆</div>
<h1>闯关完成!</h1>
<div class="stars" id="endStars"></div>
<div class="score-big" id="endScore"></div>
<p class="subtitle" id="endMsg"></p>
<button class="btn b-green big" onclick="restart()">🔁 再玩一次</button>
<button class="btn b-white" onclick="quit()">🏠 回首页</button>
</div>
</div>
<div class="sheet" id="sheet">
<div class="head"><span class="icon" id="sheetIcon">🎉</span>
<div><div class="s-title" id="sheetTitle"></div><div class="s-sub" id="sheetSub"></div></div></div>
<button class="cont" id="contBtn">继续 →</button>
</div>
<div class="flash" id="flash"></div>
<div class="fx" id="fx"></div>
<script>
/* ============ 可配置项 ============ */
const CONFIG = {
ttsQuestion:true, // 自动朗读题目
readRate:0.6, // 读题语速(1=正常,越小越慢)
tableMin:1, tableMax:9, // 口诀范围:只出「几开头」到「几开头」的口诀(全局,三种模式都生效)
timerEnabled:true, // 限时作答
timeLimitMs:6000, // 每题作答窗口(读题后开始计时/录音)
sessionLength:20, // 随机模式一局题数
slowMs:3000, // 超过多久算“答得慢”
// 随机模式错题加权
wrongPenalty:4, timeoutPenalty:5, slowAddPenalty:1.5,
correctFastFactor:0.4, correctSlowFactor:0.8, minWeight:1, maxWeight:14,
seqTeachAfter:1, // 顺序模式错几次后自动念口诀
// 录音 / 静音检测(VAD)
vadThreshold:0.018, // 音量阈值(0-1),超过算“在说话”
vadStartMs:100, // 连续多久有声音算“开口了”(太大会漏掉“一”这类短促单音节)
vadSilenceMs:1100, // 开口后静音多久算“说完了”(放宽以容忍背口诀“三七…二十一”中间的停顿)
maxAttempts:3, // 一题内没听清最多自动重录几次
noMicWindowMs:8000, // 关闭限时时的最长录音窗口
// 抢答模式
matchBuzzMs:5000, // 出题后多久内可以抢答
matchAnswerMs:6000, // 抢到后必须在几秒内作答A 秒)
matchQuestions:10, // 一局题数
matchWinPts:5, // 答对得分
matchLosePts:2, // 答错/超时扣分
};
/* ============ 口诀数据 ============ */
const CN = ['零','一','二','三','四','五','六','七','八','九'];
function numToCn(n){ if(n<10)return CN[n]; if(n===10)return '一十'; if(n<20)return '十'+CN[n%10];
const t=Math.floor(n/10),o=n%10; return CN[t]+'十'+(o?CN[o]:''); }
function kouOf(a,b){ const p=a*b; return CN[a]+CN[b]+(p<10?'得'+CN[p]:numToCn(p)); }
// 读题(口诀填空式):个位数答案读“一三得”,两位数答案读“三七”,把得数留给小朋友接
function kouPrompt(a,b){ const p=a*b; return CN[a]+CN[b]+(p<10?'得':''); }
function buildProblems(){ const list=[];
const lo=Math.min(CONFIG.tableMin,CONFIG.tableMax), hi=Math.max(CONFIG.tableMin,CONFIG.tableMax);
for(let a=lo;a<=hi;a++) for(let b=a;b<=9;b++) list.push({a,b,p:a*b,kou:kouOf(a,b),weight:CONFIG.minWeight});
return list; }
/* ============ 中文/数字解析 ============ */
const DIG = {:0,:0,:1,:1,:2,:2,:2,:3,:4,:5,:6,:7,:8,:9,
// 发音兼容别名(与后端 grammar 的 alias 规则配套n/l 不分、同音变调 → 映射回数字
:6,:6,:6,:6,:9,:9};
function parseNumToken(s){
if(/^[0-9]+$/.test(s)) return parseInt(s,10);
if(s.includes('十')){ const i=s.indexOf('十'); const a=s.slice(0,i), b=s.slice(i+1);
const tens=(a==='')?1:(a in DIG?DIG[a]:NaN); const ones=(b==='')?0:(b in DIG?DIG[b]:NaN);
if(isNaN(tens)||isNaN(ones)) return null; return tens*10+ones; }
let n=0,ok=false; for(const ch of s){ if(ch in DIG){n=n*10+DIG[ch];ok=true;} else return null; } return ok?n:null;
}
function extractLastNumber(text){
const t=(text||'').replace(/\s/g,''); let toks=[],cur='';
const isNum=ch=> /[0-9]/.test(ch) || (ch in DIG) || ch==='十';
for(const ch of t){ if(isNum(ch)) cur+=ch; else { if(cur){toks.push(cur);cur='';} } }
if(cur) toks.push(cur);
const nums=toks.map(parseNumToken).filter(n=>n!==null);
return nums.length? nums[nums.length-1] : null;
}
function hasNumber(t){ return extractLastNumber(t)!==null; }
// 剥掉标点/口语填充词/运算词,再剥掉句首因数前缀(正反序、中/数字),只留“得数”部分
function stripKou(transcript,a,b){
let t=(transcript||'').replace(/[\s.,,。!!?、~~·=]/g,'')
.replace(/得到|得|等于|结果|答案|应该|就是|是|的|啦|了|嘛|呀|哦|噢|嗯|呃|那|个/g,'')
.replace(/乘以|乘上|乘|×|✕|X|x|\*/g,'');
const prefixes=[CN[a]+CN[b], ''+a+b, CN[b]+CN[a], ''+b+a];
for(const pre of prefixes){ if(pre && t.startsWith(pre)){ t=t.slice(pre.length); break; } }
return t;
}
function judge(transcript,a,b,p){
if(!transcript) return null;
const t=stripKou(transcript,a,b);
if(parseNumToken(t)===p) return true;
if(extractLastNumber(t)===p) return true;
if(extractLastNumber(transcript)===p) return true;
return false;
}
// 是否“只念了口诀前半(因数)还没说得数”,如背到“三七…”停顿被切断 → 不算错,继续听
function isJustFactors(transcript,a,b){
let t=(transcript||'').replace(/[\s.,,。!!?、~~·=]/g,'')
.replace(/得到|得|等于|结果|答案|应该|就是|是|的|啦|了|嘛|呀|哦|噢|嗯|呃|那|个/g,'')
.replace(/乘以|乘上|乘|×|✕|X|x|\*/g,'');
if(!t) return false;
const forms=[CN[a]+CN[b], ''+a+b, CN[b]+CN[a], ''+b+a];
if(forms.includes(t)) return true;
const n=extractLastNumber(t);
return n!==null && n!==a*b && (n===a*10+b || n===b*10+a || n===a || n===b);
}
// 疑似“十”被吞:得数是十几、只听到个位(如 2×7=14 只识别出“四”)→ 不判错,重录一次
function isTeenDroppedTen(transcript,a,b,p){
if(p<10||p>19) return false;
const n=extractLastNumber(stripKou(transcript,a,b));
return n===p%10;
}
// 疑似平翘舌/语序混淆:得数十几却听成 X0十四→四十或整十却听成十X三十→十三→ 不判错,重录一次
function isSwapConfusion(transcript,a,b,p){
const n=extractLastNumber(stripKou(transcript,a,b));
if(n===null) return false;
if(p>=11&&p<=19 && n===(p%10)*10) return true;
if(p%10===0&&p>=10&&p<=90 && n===10+p/10) return true;
return false;
}
/* ============ 语音播放TTS ============ */
let zhVoice=null;
function pickVoice(){ const vs=speechSynthesis.getVoices(); zhVoice=vs.find(v=>/zh/i.test(v.lang))||vs[0]||null; }
if('speechSynthesis' in window){ pickVoice(); speechSynthesis.onvoiceschanged=pickVoice; }
function speak(text){ if(!('speechSynthesis' in window))return; speechSynthesis.cancel();
const u=new SpeechSynthesisUtterance(text); u.lang='zh-CN'; u.rate=.9; u.pitch=1.15; if(zhVoice)u.voice=zhVoice; speechSynthesis.speak(u); }
function speakAsync(text,cancelFirst){
return new Promise(res=>{
if(!('speechSynthesis' in window)){ res(); return; }
if(cancelFirst) speechSynthesis.cancel();
const u=new SpeechSynthesisUtterance(text); u.lang='zh-CN'; u.rate=CONFIG.readRate; u.pitch=1.15; if(zhVoice)u.voice=zhVoice;
let done=false; const fin=()=>{ if(!done){done=true;res();} };
u.onend=fin; u.onerror=fin; speechSynthesis.speak(u);
setTimeout(fin, 4500); // iOS 兜底
});
}
/* ============ 录音 + 静音检测 + 后端识别 ============ */
let mediaStream=null, audioCtx=null, analyser=null, vadBuf=null;
let mediaRecorder=null, recChunks=[], recording=false, recStart=0, recWindow=0,
speechStarted=false, lastVoiceAt=0, voiceRun=0, vadRAF=null, attempts=0;
const canRecord = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia && window.MediaRecorder);
async function ensureAudio(){
// 解锁 iOS 的 TTS需在用户手势内朗读一次
try{ speechSynthesis.cancel(); const u=new SpeechSynthesisUtterance(' '); u.volume=0; speechSynthesis.speak(u); }catch(e){}
if(mediaStream) { if(audioCtx&&audioCtx.state==='suspended'){ try{await audioCtx.resume();}catch(e){} } return true; }
if(!canRecord) return false;
try{
mediaStream=await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true,autoGainControl:true}});
const AC=window.AudioContext||window.webkitAudioContext;
audioCtx=new AC(); if(audioCtx.state==='suspended'){ try{await audioCtx.resume();}catch(e){} }
const src=audioCtx.createMediaStreamSource(mediaStream);
analyser=audioCtx.createAnalyser(); analyser.fftSize=1024; vadBuf=new Float32Array(analyser.fftSize);
src.connect(analyser);
return true;
}catch(e){ return false; }
}
function pickMime(){
if(!window.MediaRecorder) return '';
const opts=['audio/webm;codecs=opus','audio/webm','audio/mp4','audio/aac','audio/ogg'];
for(const o of opts){ try{ if(MediaRecorder.isTypeSupported(o)) return o; }catch(e){} }
return '';
}
function autoWindowMs(){
// 抢答模式A 秒是从“抢到”那一刻起的硬截止,重录只能用剩余时间,不重置窗口
if(matchMode) return Math.max(800, buzzAt ? buzzAt+CONFIG.matchAnswerMs-Date.now() : CONFIG.matchAnswerMs);
return CONFIG.timerEnabled ? CONFIG.timeLimitMs : CONFIG.noMicWindowMs;
}
// 抢答作答是否已到硬截止(留 150ms 余量,避免边界上再重录一轮)
function matchOvertime(){ return matchMode && buzzAt && (Date.now()-buzzAt) >= CONFIG.matchAnswerMs-150; }
// 所有作答路径的路由:抢答模式走 matchResolve单人模式走 resolve
function settle(correct,timedOut){ matchMode ? matchResolve(correct,timedOut) : resolve(correct,timedOut); }
function startRecordCycle(){
if(!mediaStream || answered) return;
recChunks=[]; speechStarted=false; voiceRun=0; lastVoiceAt=0;
const mime=pickMime();
try{ mediaRecorder = mime? new MediaRecorder(mediaStream,{mimeType:mime}) : new MediaRecorder(mediaStream); }
catch(e){ try{ mediaRecorder=new MediaRecorder(mediaStream); }catch(_){ switchToPad(); return; } }
mediaRecorder.ondataavailable=e=>{ if(e.data&&e.data.size) recChunks.push(e.data); };
mediaRecorder.onstop=onRecStop;
recording=true;
try{ mediaRecorder.start(); }catch(e){ recording=false; switchToPad(); return; }
recStart=Date.now(); recWindow=autoWindowMs(); // 窗口在开录时定死,录音中不再变
micBtn.className='mic listening'; micLabel.textContent='我在听… 说答案!';
heard.innerHTML='<span class="dim">🎙️ 请说答案…</span>';
startTimer();
vadRAF=requestAnimationFrame(vadTick);
}
function vadTick(){
if(!recording) return;
let rms=0;
if(analyser){ analyser.getFloatTimeDomainData(vadBuf); let s=0; for(let i=0;i<vadBuf.length;i++) s+=vadBuf[i]*vadBuf[i]; rms=Math.sqrt(s/vadBuf.length); }
const now=Date.now();
if(rms>CONFIG.vadThreshold){ voiceRun+=16; if(voiceRun>=CONFIG.vadStartMs) speechStarted=true; lastVoiceAt=now; }
else { voiceRun=0; }
if(speechStarted && lastVoiceAt && (now-lastVoiceAt)>=CONFIG.vadSilenceMs){ stopRecord(); return; } // 说完了
if((now-recStart)>=recWindow){ stopRecord(); return; } // 到时间
vadRAF=requestAnimationFrame(vadTick);
}
function stopRecord(){
if(!recording) return; recording=false;
if(vadRAF){ cancelAnimationFrame(vadRAF); vadRAF=null; }
clearTimer(); micBtn.className='mic';
try{ if(mediaRecorder && mediaRecorder.state!=='inactive') mediaRecorder.stop(); }catch(e){}
}
async function onRecStop(){
if(answered) return;
const type=(mediaRecorder&&mediaRecorder.mimeType)||'audio/webm';
const blob=new Blob(recChunks,{type});
if(!speechStarted || blob.size<1500){ // 根本没开口
attempts++;
if(attempts>=CONFIG.maxAttempts || matchOvertime()){ settle(false,true); } // 抢答A 秒硬截止,直接判超时
else { heard.innerHTML='<span class="dim">没听到声音,再说一次~</span>'; later(()=>{ if(!answered) startRecordCycle(); },250); }
return;
}
micBtn.className='mic think'; micLabel.textContent='识别中…'; heard.innerHTML='🧠 识别中…';
try{
const r=await fetch('/stt?a='+cur.a+'&b='+cur.b,{method:'POST',headers:{'Content-Type':blob.type||'application/octet-stream'},body:blob});
const j=await r.json();
if(answered) return;
const text=(j.text||'').trim();
const verd=judge(text,cur.a,cur.b,cur.p);
heard.innerHTML = text? ('👂 '+text) : '<span class="dim">没听清</span>';
micBtn.className='mic'; micLabel.textContent='点我再说一次';
if(verd===true){ settle(true,false); }
else if(isJustFactors(text,cur.a,cur.b)){ // 只背了口诀前半(因数) → 当作没说完,继续听
attempts++;
heard.innerHTML='👂 '+text+' …';
if(attempts>=CONFIG.maxAttempts || matchOvertime()){ settle(false,matchOvertime()); }
else later(()=>{ if(!answered) startRecordCycle(); },250);
}
else if(isTeenDroppedTen(text,cur.a,cur.b,cur.p) // 疑似“十”被吞(十几只听到个位)
|| isSwapConfusion(text,cur.a,cur.b,cur.p)){ // 或平翘舌/语序混淆(十四↔四十) → 不冤枉,再听一次
attempts++;
heard.innerHTML='👂 '+text+'?再说一次~';
if(attempts>=CONFIG.maxAttempts || matchOvertime()){ settle(false,matchOvertime()); }
else later(()=>{ if(!answered) startRecordCycle(); },250);
}
else if(text && hasNumber(text)){ settle(false,false); } // 说了个数、但答错
else { // 没识别出数字 → 再给机会
attempts++;
if(attempts>=CONFIG.maxAttempts || matchOvertime()){ settle(false,matchOvertime()); }
else later(()=>{ if(!answered) startRecordCycle(); },300);
}
}catch(e){
if(answered) return;
micBtn.className='mic'; heard.innerHTML='⚠️ 识别服务未连接';
attempts++;
if(attempts>=CONFIG.maxAttempts){ settle(false,true); }
else later(()=>{ if(!answered) startRecordCycle(); },700);
}
}
async function autoAsk(){
if(answered || !autoActive) return;
attempts=0;
if(CONFIG.ttsQuestion){ heard.innerHTML='<span class="dim">🔊 听题目…</span>'; await speakAsync(kouPrompt(cur.a,cur.b), true); }
if(answered || !autoActive) return;
startRecordCycle();
}
function micTap(){
if(!autoActive){ if(!matchMode) switchToPad(); return; }
if(recording){ stopRecord(); } // 说完了,立刻识别
else if(!answered){ matchMode ? startRecordCycle() : autoAsk(); } // 再答一次(抢答模式不重读题)
}
/* ============ 计时器rAF 逐帧驱动,丝滑不跳格) ============ */
let timerId=null, qStart=0;
function clearTimer(){ if(timerId){ cancelAnimationFrame(timerId); timerId=null; } }
function startTimer(){
clearTimer(); qStart=Date.now();
if(!matchMode && !CONFIG.timerEnabled){ timerWrapEl.classList.add('hide'); return; } // 抢答模式恒定限时
timerWrapEl.classList.remove('hide');
const bar=timerBarEl, win=autoWindowMs(); bar.style.width='100%'; bar.style.background='var(--green)';
const tick=()=>{
const el=Date.now()-qStart, left=Math.max(0,win-el), pct=left/win*100;
bar.style.width=pct+'%';
bar.style.background = pct<30?'var(--red)':(pct<60?'var(--yellow)':'var(--green)');
if(el>=win){ timerId=null; if(!autoActive) settle(false,true); return; } // 录音模式由 VAD 结束
timerId=requestAnimationFrame(tick);
};
timerId=requestAnimationFrame(tick);
}
/* ============ 游戏状态 ============ */
let mode='seq', problems=[], idx=0, score=0, cur=null, padMode=false, answered=false,
answeredCount=0, missCount=0, lastKey='', pendingAdv=null, autoActive=false;
// 代际守卫:退出/换局时 epoch+1旧局排下的延迟回调下一题推进、自动重录等全部失效
// 防止“退出比赛后残留定时器把结果卡弹到首页 / 污染下一局”
let epoch=0;
function later(fn,ms){ const e=epoch; return setTimeout(()=>{ if(e===epoch) fn(); },ms); }
// 口诀范围持久化(全局设置,换端复用)
try{ const r=JSON.parse(localStorage.getItem('calx-range')||'null');
if(r && r.min>=1 && r.max<=9 && r.min<=r.max){ CONFIG.tableMin=r.min; CONFIG.tableMax=r.max; } }catch(e){}
function saveRange(){ try{ localStorage.setItem('calx-range',JSON.stringify({min:CONFIG.tableMin,max:CONFIG.tableMax})); }catch(e){} }
function setRange(which,v){
v=Math.min(9,Math.max(1,parseInt(v,10)||1));
if(which==='min'){ CONFIG.tableMin=v; if(CONFIG.tableMax<v) CONFIG.tableMax=v; }
else { CONFIG.tableMax=v; if(CONFIG.tableMin>v) CONFIG.tableMin=v; }
$('cfgRangeMin').value=CONFIG.tableMin; $('cfgRangeMax').value=CONFIG.tableMax; saveRange();
}
const $=id=>document.getElementById(id);
const home=$('home'),game=$('game'),end=$('end');
const problem=$('problem'),kouHint=$('kouHint'),gMascot=$('gMascot');
// 录音闭环的展示元素可重指向:单人模式指向 game 页,抢答模式指向 match 页中列
let micBtn=$('micBtn'),micLabel=$('micLabel'),heard=$('heard'),
timerWrapEl=$('timerWrap'),timerBarEl=$('timerBar');
function bindRecUI(m){
micBtn=$(m?'mMic':'micBtn'); micLabel=$(m?'mMicLabel':'micLabel'); heard=$(m?'mHeard':'heard');
timerWrapEl=$(m?'mTimerWrap':'timerWrap'); timerBarEl=$(m?'mTimerBar':'timerBar');
}
let seed=(Date.now()%100000)+1;
function rnd(){ seed=(seed*9301+49297)%233280; return seed/233280; }
function pickWeighted(){
let total=0; for(const p of problems) total+=p.weight;
for(let tries=0;tries<6;tries++){
let r=rnd()*total, chosen=problems[0];
for(const p of problems){ r-=p.weight; if(r<=0){ chosen=p; break; } }
if((chosen.a+'x'+chosen.b)!==lastKey || problems.length<2) return chosen;
}
return problems[Math.floor(rnd()*problems.length)];
}
async function startGame(m){
epoch++; mode=m; score=0; idx=0; answeredCount=0; lastKey='';
problems=buildProblems(); $('scorePill').textContent='⭐0';
home.classList.add('hide'); end.classList.add('hide'); game.classList.remove('hide');
padMode=false;
const ok = await ensureAudio(); // 在“开始”手势内解锁音频并申请麦克风
if(ok){ autoActive=true; $('micWrap').classList.remove('hide'); $('padWrap').classList.add('hide'); $('toggleInput').innerHTML='⌨️ 用手写'; }
else { autoActive=false; switchToPad(); micLabel.textContent='麦克风不可用'; }
nextQuestion();
}
function totalTarget(){ return mode==='seq'? problems.length : CONFIG.sessionLength; }
function progressDone(){ return mode==='seq'? idx : answeredCount; }
function nextQuestion(){
if( (mode==='seq' && idx>=problems.length) || (mode==='rand' && answeredCount>=CONFIG.sessionLength) ) return finish();
cur = (mode==='seq') ? problems[idx] : pickWeighted();
lastKey = cur.a+'x'+cur.b; missCount=0;
renderQuestion();
}
function retrySame(){ renderQuestion(); }
function renderQuestion(){
answered=false; attempts=0;
problem.innerHTML = `${cur.a} × ${cur.b} = <span class="q">?</span>`;
problem.classList.remove('pop'); void problem.offsetWidth; problem.classList.add('pop');
kouHint.textContent=''; gMascot.textContent='🦉';
$('barFill').style.width=(progressDone()/totalTarget()*100)+'%';
padBuf=''; $('padShow').textContent='点数字输入答案'; $('padShow').className='dim';
if(autoActive){ heard.innerHTML='<span class="dim">准备好啦~</span>'; micBtn.className='mic'; later(autoAsk,250); }
else { heard.innerHTML='<span class="dim">点数字输入答案</span>'; if(CONFIG.ttsQuestion) speakAsync(kouPrompt(cur.a,cur.b),true); startTimer(); }
}
function resolve(correct, timedOut){
if(answered) return; answered=true;
const elapsed = Date.now()-qStart;
clearTimer(); recording=false; if(vadRAF){ cancelAnimationFrame(vadRAF); vadRAF=null; }
try{ if(mediaRecorder && mediaRecorder.state==='recording') mediaRecorder.stop(); }catch(e){}
micBtn.className='mic';
if(mode==='rand') updateWeight(cur, correct && !timedOut, timedOut, elapsed);
if(correct && !timedOut){
score++; $('scorePill').textContent='⭐'+score;
if(mode==='seq') idx++; else answeredCount++;
$('barFill').style.width=(progressDone()/totalTarget()*100)+'%';
positive(elapsed); speak(cur.kou);
afterSheet('ok', elapsed<CONFIG.slowMs?'太棒了! 🎉':'答对啦! 👍', cur.kou, nextQuestion, 1500);
} else {
negative(timedOut);
if(mode==='seq'){
missCount++; if(missCount>=CONFIG.seqTeachAfter) speak(cur.kou);
afterSheet('no', timedOut?'时间到! ⏰':'再试试~ 💪', '正确口诀:'+cur.kou, retrySame, 1800);
} else {
answeredCount++; $('barFill').style.width=(progressDone()/totalTarget()*100)+'%'; speak(cur.kou);
afterSheet('no', timedOut?'时间到! ⏰':'答错了 💪', '正确:'+cur.a+'×'+cur.b+'='+cur.p+''+cur.kou+'', nextQuestion, 1900);
}
}
}
function updateWeight(p, correct, timedOut, elapsed){
if(timedOut){ p.weight = Math.min(CONFIG.maxWeight, p.weight + CONFIG.timeoutPenalty); return; }
if(!correct){ p.weight = Math.min(CONFIG.maxWeight, p.weight + CONFIG.wrongPenalty); return; }
const slow = elapsed > CONFIG.slowMs;
p.weight = Math.max(CONFIG.minWeight, p.weight * (slow?CONFIG.correctSlowFactor:CONFIG.correctFastFactor));
if(slow) p.weight = Math.min(CONFIG.maxWeight, p.weight + CONFIG.slowAddPenalty);
}
/* ============ 结果卡 + 反馈 ============ */
function afterSheet(type,title,sub,thenFn,delay){
const s=$('sheet'); s.className='sheet '+type+' show';
$('sheetIcon').textContent = type==='ok'?'🎉':'💡';
$('sheetTitle').textContent=title; $('sheetSub').textContent=sub;
const go=()=>{ s.classList.remove('show'); thenFn&&thenFn(); };
clearTimeout(pendingAdv); pendingAdv=later(go,delay);
$('contBtn').onclick=()=>{ clearTimeout(pendingAdv); go(); };
}
function positive(elapsed){
gMascot.textContent = elapsed<CONFIG.slowMs?'🥳':'😄';
gMascot.classList.remove('pop'); void gMascot.offsetWidth; gMascot.classList.add('pop');
kouHint.textContent=cur.kou; beep(true); flash('g'); confetti(elapsed<CONFIG.slowMs?22:14);
}
function negative(timedOut){
gMascot.textContent = timedOut?'😮':'🤔';
problem.classList.remove('shake'); void problem.offsetWidth; problem.classList.add('shake');
kouHint.textContent=cur.kou; beep(false); flash('r');
}
function flash(kind){ const f=$('flash'); f.className='flash'; void f.offsetWidth; f.className='flash '+kind; }
/* ============ 听口诀 / 键盘 / 退出 ============ */
function speakKou(){
if(recording) stopRecord();
clearTimer(); kouHint.textContent=cur.kou;
speakAsync(cur.kou,true).then(()=>{ if(!answered && autoActive) startRecordCycle(); });
}
let padBuf='';
function toggleInputMode(){ padMode ? switchToMic() : switchToPad(); }
function switchToPad(){
padMode=true; autoActive=false; if(recording) stopRecord(); clearTimer();
$('micWrap').classList.add('hide'); $('padWrap').classList.remove('hide'); $('toggleInput').innerHTML='🎤 用说的';
if(!answered){ startTimer(); }
}
function switchToMic(){
if(!mediaStream){ ensureAudio().then(ok=>{ if(ok){ padMode=false; autoActive=true; $('padWrap').classList.add('hide'); $('micWrap').classList.remove('hide'); $('toggleInput').innerHTML='⌨️ 用手写'; if(!answered) autoAsk(); } }); return; }
padMode=false; autoActive=true;
$('padWrap').classList.add('hide'); $('micWrap').classList.remove('hide'); $('toggleInput').innerHTML='⌨️ 用手写';
if(!answered) autoAsk();
}
function padKey(k){ if(padBuf.length<2){ padBuf+=k; $('padShow').textContent=padBuf; $('padShow').className=''; } }
function padDel(){ padBuf=padBuf.slice(0,-1); $('padShow').textContent=padBuf||'点数字输入答案'; if(!padBuf)$('padShow').className='dim'; }
function padGo(){ if(!padBuf||answered)return; const v=parseInt(padBuf,10); padBuf=''; settle(v===cur.p,false); }
function finish(){
clearTimer(); if(recording) stopRecord(); $('barFill').style.width='100%';
const total=totalTarget(), pct= total? score/total : 0;
$('endStars').textContent = pct>=.9?'⭐⭐⭐': pct>=.6?'⭐⭐':'⭐';
$('endScore').textContent = score+' / '+total;
$('endMascot').textContent = pct>=.9?'🏆': pct>=.6?'🎖️':'🌟';
$('endMsg').textContent = pct>=.9?'太厉害啦,口诀全会背!': pct>=.6?'很棒!再多练几次就满分!':'继续加油,你一定行!';
game.classList.add('hide'); end.classList.remove('hide');
speak(pct>=.9?'太厉害啦!':'闯关完成,继续加油!'); bigConfetti();
}
function restart(){ startGame(mode); }
function quit(){
epoch++; answered=true; clearTimer(); if(recording) stopRecord(); clearTimeout(pendingAdv);
if(matchMode) exitMatch();
speechSynthesis&&speechSynthesis.cancel(); $('sheet').classList.remove('show');
game.classList.add('hide'); end.classList.add('hide'); $('match').classList.add('hide');
home.classList.remove('hide');
}
/* ============ 抢答模式(左右玩家按键抢答 → 抢到者语音作答) ============ */
let matchMode=false, mState='lobby', buzzer=null, buzzAt=0, mScores={L:0,R:0}, mQIdx=0,
buzzTimerId=null, padPollRAF=null, padPrev={}, capturing=null, matchOrder='rand';
function matchTotal(){ return matchOrder==='seq' ? problems.length : CONFIG.matchQuestions; }
function setMatchOrder(o){
matchOrder=o;
$('mOrderSeq').className='seg'+(o==='seq'?' on':''); $('mOrderRand').className='seg'+(o==='rand'?' on':'');
}
// 抢答键绑定:{type:'key',code}(键盘)或 {type:'pad',pad,button}(蓝牙手柄),存 localStorage
let buzzKeys={ L:{type:'key',code:'KeyA',label:'键盘 A'}, R:{type:'key',code:'KeyL',label:'键盘 L'} };
try{ const s=JSON.parse(localStorage.getItem('calx-buzz')||'null'); if(s&&s.L&&s.R) buzzKeys=s; }catch(e){}
function saveBuzzKeys(){ try{ localStorage.setItem('calx-buzz',JSON.stringify(buzzKeys)); }catch(e){} }
const sameBind=(x,y)=> x&&y&&x.type===y.type &&
(x.type==='key' ? x.code===y.code : (x.pad===y.pad&&x.button===y.button));
function renderBindings(){
$('keyL').textContent='🔑 '+buzzKeys.L.label; $('keyR').textContent='🔑 '+buzzKeys.R.label;
for(const side of ['L','R']){
const b=$('bind'+side);
b.textContent = capturing===side?'请按键…':'🔧 绑定按键';
b.className = 'bindbtn'+(capturing===side?' capturing':'');
}
}
function bindCapture(side){
if(mState!=='lobby'&&mState!=='end') return;
capturing = capturing===side? null : side;
renderBindings();
}
function setBinding(side,bind){
if(sameBind(buzzKeys[side==='L'?'R':'L'],bind)){
heard.innerHTML='<span class="dim">这个键已经绑给另一边啦,换一个~</span>';
capturing=null; renderBindings(); return;
}
buzzKeys[side]=bind; saveBuzzKeys(); capturing=null; renderBindings();
heard.innerHTML='<span class="dim">'+(side==='L'?'🐯 左边':'🐰 右边')+'绑定成功:'+bind.label+'</span>';
}
function matchKeydown(e){
if(!matchMode) return;
if(capturing){
if(e.code==='Escape'){ capturing=null; renderBindings(); return; }
e.preventDefault();
setBinding(capturing,{type:'key',code:e.code,label:'键盘 '+(e.key&&e.key.trim().length===1?e.key.toUpperCase():e.code)});
return;
}
if(mState!=='buzz') return;
if(buzzKeys.L.type==='key'&&e.code===buzzKeys.L.code){ e.preventDefault(); buzz('L'); }
else if(buzzKeys.R.type==='key'&&e.code===buzzKeys.R.code){ e.preventDefault(); buzz('R'); }
}
// 手柄按键轮询:既做绑定录入,也做抢答判定(按下沿触发)
function padPoll(){
if(!matchMode){ padPollRAF=null; return; }
const pads=(navigator.getGamepads&&navigator.getGamepads())||[];
for(let gi=0; gi<pads.length; gi++){
const gp=pads[gi]; if(!gp) continue;
for(let bi=0; bi<gp.buttons.length; bi++){
const k=gi+':'+bi, down=gp.buttons[bi].pressed;
if(down && !padPrev[k]){
const bind={type:'pad',pad:gi,button:bi,label:'手柄'+(gi+1)+'·键'+bi};
if(capturing) setBinding(capturing,bind);
else if(mState==='buzz'){
if(sameBind(buzzKeys.L,bind)) buzz('L');
else if(sameBind(buzzKeys.R,bind)) buzz('R');
}
}
padPrev[k]=down;
}
}
padPollRAF=requestAnimationFrame(padPoll);
}
async function enterMatch(){
epoch++; matchMode=true; mState='lobby'; buzzer=null; capturing=null; answered=true;
home.classList.add('hide'); end.classList.add('hide'); $('match').classList.remove('hide');
bindRecUI(true);
$('mPadSlot').appendChild($('padWrap')); $('padWrap').classList.add('hide');
$('mLobby').classList.remove('hide'); $('mEnd').classList.add('hide');
$('mMicWrap').classList.add('hide'); $('mTimerWrap').classList.add('hide');
$('mProblem').innerHTML=''; $('mKouHint').textContent=''; $('mQnum').textContent='双人抢答';
$('mMascot').textContent='🦉';
['pL','pR'].forEach(id=>{ $(id).className='player'; });
$('tagL').classList.add('hide'); $('tagR').classList.add('hide');
mScores={L:0,R:0}; renderScores(); renderBindings();
document.addEventListener('keydown',matchKeydown);
padPrev={}; if(!padPollRAF) padPollRAF=requestAnimationFrame(padPoll);
const ok=await ensureAudio(); // 在入口手势内解锁音频/麦克风
autoActive=ok; padMode=!ok;
heard.innerHTML = ok
? '<span class="dim">先绑定两边的抢答键,然后开始!</span>'
: '<span class="dim">麦克风不可用:抢到后用数字键盘作答</span>';
}
function exitMatch(){
matchMode=false; mState='lobby'; capturing=null;
stopBuzzCountdown();
document.removeEventListener('keydown',matchKeydown);
if(padPollRAF){ cancelAnimationFrame(padPollRAF); padPollRAF=null; }
// 数字键盘归还单人游戏页
$('padWrap').classList.add('hide');
game.insertBefore($('padWrap'), game.querySelector('.tools'));
bindRecUI(false);
}
function renderScores(hl){
$('scoreL').textContent=mScores.L; $('scoreR').textContent=mScores.R;
if(hl){ const el=$(hl==='L'?'scoreL':'scoreR'); el.classList.remove('pop'); void el.offsetWidth; el.classList.add('pop'); }
}
function matchStart(){
epoch++; mScores={L:0,R:0}; mQIdx=0; renderScores();
problems=buildProblems(); lastKey=''; capturing=null; renderBindings();
$('bindL').classList.add('hide'); $('bindR').classList.add('hide'); // 比赛中不允许改绑定
$('mLobby').classList.add('hide'); $('mEnd').classList.add('hide');
['pL','pR'].forEach(id=>{ $(id).className='player'; });
matchNext();
}
async function matchNext(){
if(!matchMode) return;
if(mQIdx>=matchTotal()) return matchFinish();
answered=false; attempts=0; buzzer=null; buzzAt=0;
['pL','pR'].forEach(id=>$(id).classList.remove('buzzed','shake'));
$('tagL').classList.add('hide'); $('tagR').classList.add('hide');
$('mMicWrap').classList.add('hide'); $('padWrap').classList.add('hide'); $('mTimerWrap').classList.add('hide');
let c;
if(matchOrder==='seq'){ c=problems[mQIdx]; } // 顺序:按口诀表顺序过一遍
else { // 随机:均匀抽题,避免连续重复
c=problems[Math.floor(rnd()*problems.length)];
for(let i=0;i<6 && (c.a+'x'+c.b)===lastKey && problems.length>1;i++) c=problems[Math.floor(rnd()*problems.length)];
}
cur=c; lastKey=c.a+'x'+c.b; mQIdx++;
$('mQnum').textContent='第 '+mQIdx+' / '+matchTotal()+' 题';
$('mMascot').textContent='🦉';
$('mProblem').innerHTML=`${cur.a} × ${cur.b} = <span class="q">?</span>`;
$('mProblem').classList.remove('pop'); void $('mProblem').offsetWidth; $('mProblem').classList.add('pop');
$('mKouHint').textContent='';
heard.innerHTML='<span class="dim">🔊 听题…可以抢答了!</span>';
mState='buzz'; // 读题期间就可以抢
if(CONFIG.ttsQuestion) await speakAsync(kouPrompt(cur.a,cur.b),true);
if(!matchMode || mState!=='buzz') return; // 读题时已被抢答或已退出
heard.innerHTML='⚡ 抢答!快按你的键!';
startBuzzCountdown();
}
function startBuzzCountdown(){
stopBuzzCountdown();
$('mTimerWrap').classList.remove('hide');
const bar=$('mTimerBar'); bar.style.width='100%'; bar.style.background='var(--yellow)';
const t0=Date.now();
const tick=()=>{
const left=Math.max(0,CONFIG.matchBuzzMs-(Date.now()-t0));
bar.style.width=(left/CONFIG.matchBuzzMs*100)+'%';
if(!left){ buzzTimerId=null; noBuzz(); return; }
buzzTimerId=requestAnimationFrame(tick);
};
buzzTimerId=requestAnimationFrame(tick);
}
function stopBuzzCountdown(){ if(buzzTimerId){ cancelAnimationFrame(buzzTimerId); buzzTimerId=null; } }
function noBuzz(){
if(mState!=='buzz') return;
mState='between'; answered=true;
$('mTimerWrap').classList.add('hide');
$('mMascot').textContent='😴';
heard.innerHTML='⏰ 没人抢答~'; $('mKouHint').textContent=cur.kou;
speak(cur.kou);
later(()=>{ if(matchMode) matchNext(); },1800);
}
function buzz(side){
if(mState!=='buzz') return;
mState='answer'; buzzer=side; buzzAt=Date.now();
stopBuzzCountdown(); if(window.speechSynthesis) speechSynthesis.cancel();
$(side==='L'?'pL':'pR').classList.add('buzzed');
$(side==='L'?'tagL':'tagR').classList.remove('hide');
beep(true); flash('g');
$('mMascot').textContent='😲';
heard.innerHTML=(side==='L'?'🐯 左边':'🐰 右边')+'抢到了!快说答案!';
if(autoActive){ $('mMicWrap').classList.remove('hide'); startRecordCycle(); }
else {
$('padWrap').classList.remove('hide');
padBuf=''; $('padShow').textContent='点数字输入答案'; $('padShow').className='dim';
startTimer();
}
}
function matchResolve(correct,timedOut){
if(answered) return; answered=true; mState='between';
clearTimer(); recording=false; if(vadRAF){ cancelAnimationFrame(vadRAF); vadRAF=null; }
try{ if(mediaRecorder && mediaRecorder.state==='recording') mediaRecorder.stop(); }catch(e){}
micBtn.className='mic';
$('mMicWrap').classList.add('hide'); $('padWrap').classList.add('hide'); $('mTimerWrap').classList.add('hide');
const side=buzzer||'L', name= side==='L'?'🐯 左边':'🐰 右边';
$('mKouHint').textContent=cur.kou;
if(correct){
mScores[side]+=CONFIG.matchWinPts;
$('mMascot').textContent='🥳';
heard.innerHTML='🎉 '+name+'答对啦,+'+CONFIG.matchWinPts+' 分!';
beep(true); flash('g'); confetti(18);
} else {
mScores[side]-=CONFIG.matchLosePts;
$('mMascot').textContent='😮';
heard.innerHTML=(timedOut?'⏰ 超时了':'❌ 答错了')+''+name+' '+CONFIG.matchLosePts+' 分。正确是 '+cur.p;
beep(false); flash('r');
const pEl=$(side==='L'?'pL':'pR'); pEl.classList.remove('shake'); void pEl.offsetWidth; pEl.classList.add('shake');
}
renderScores(side); speak(cur.kou);
later(()=>{ if(matchMode) matchNext(); },2200);
}
function matchFinish(){
mState='end'; answered=true;
$('mProblem').innerHTML=''; $('mKouHint').textContent=''; $('mQnum').textContent='比赛结束';
$('mMicWrap').classList.add('hide'); $('padWrap').classList.add('hide'); $('mTimerWrap').classList.add('hide');
$('tagL').classList.add('hide'); $('tagR').classList.add('hide');
['pL','pR'].forEach(id=>$(id).classList.remove('buzzed'));
const l=mScores.L, r=mScores.R;
let msg, crown='👑';
if(l>r){ msg='左边小朋友是冠军!'; $('pL').classList.add('winner'); $('pR').classList.add('loser'); }
else if(r>l){ msg='右边小朋友是冠军!'; $('pR').classList.add('winner'); $('pL').classList.add('loser'); }
else { msg='平局!两位都是小冠军!'; crown='🤝'; $('pL').classList.add('winner'); $('pR').classList.add('winner'); }
$('mCrown').textContent=crown; $('mEndScore').textContent=l+' : '+r; $('mEndMsg').textContent=msg;
$('mMascot').textContent='🏆';
heard.innerHTML='🏁 '+msg;
$('mEnd').classList.remove('hide');
$('bindL').classList.remove('hide'); $('bindR').classList.remove('hide');
renderBindings();
speak(msg); bigConfetti();
}
/* ============ 音效 ============ */
function beep(good){
try{
const AC=window.AudioContext||window.webkitAudioContext;
audioCtx = audioCtx || new AC(); if(audioCtx.state==='suspended') audioCtx.resume();
const notes = good?[523,659,784,1047]:[320,200];
notes.forEach((f,i)=>{
const o=audioCtx.createOscillator(), g=audioCtx.createGain();
o.type= good?'triangle':'sawtooth'; o.frequency.value=f; o.connect(g); g.connect(audioCtx.destination);
const t=audioCtx.currentTime+i*(good?0.08:0.12);
g.gain.setValueAtTime(0.0001,t); g.gain.exponentialRampToValueAtTime(good?0.22:0.28,t+0.02);
g.gain.exponentialRampToValueAtTime(0.0001,t+(good?0.2:0.28));
o.start(t); o.stop(t+0.3);
});
}catch(e){}
}
/* ============ 彩带 ============ */
const EMO=['⭐','🎉','🌟','✨','🎈','🍬','💛','🩵','🏆'];
function confetti(n){ burst(n||16); }
function bigConfetti(){ burst(40); setTimeout(()=>burst(40),350); setTimeout(()=>burst(40),700); }
function burst(n){ const fx=$('fx');
for(let i=0;i<n;i++){ const s=document.createElement('span'); s.textContent=EMO[Math.floor(rnd()*EMO.length)];
s.style.left=(rnd()*100)+'vw'; s.style.animationDelay=(rnd()*0.3)+'s'; s.style.fontSize=(20+rnd()*22)+'px';
fx.appendChild(s); setTimeout(()=>s.remove(),1900); } }
/* ============ 设置弹窗 ============ */
function openSettings(){ $('settingsModal').classList.remove('hide'); }
function closeSettings(){ $('settingsModal').classList.add('hide'); }
/* ============ 启动初始化 ============ */
$('cfgRangeMin').value=CONFIG.tableMin; $('cfgRangeMax').value=CONFIG.tableMax; // 口诀范围回填localStorage
$('homeNote').innerHTML = canRecord
? '💡 点“开始”后允许麦克风即可全程免手'
: '💡 当前环境不支持录音,将用数字键盘作答';
</script>
</body>
</html>