Files
calx/index.html
2026-07-18 16:48:41 +08:00

652 lines
35 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>
:root{
--green:#58cc02; --green-d:#58a700; --red:#ff4b4b; --red-d:#d63535;
--blue:#1cb0f6; --blue-d:#1899d6; --yellow:#ffc800; --purple:#ce82ff;
--ink:#3c3c3c; --gray:#e5e5e5; --gray-t:#afafaf;
}
*{ box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
html,body{ height:100%; margin:0; }
body{
font-family:"PingFang SC","Microsoft YaHei",-apple-system,"Segoe UI",system-ui,sans-serif;
color:var(--ink); background:#fff;
min-height:100%; display:flex; align-items:flex-start; justify-content:center;
padding:16px; overflow-x:hidden; user-select:none;
}
.app{ width:100%; max-width:480px; }
.card{ padding:12px 6px 40px; }
h1{ text-align:center; margin:10px 0 2px; font-size:28px; font-weight:900; color:var(--green); }
.subtitle{ text-align:center; margin:0 0 18px; color:var(--gray-t); font-size:15px; font-weight:600; }
.hide{ display:none !important; }
.mascot{ font-size:78px; text-align:center; line-height:1; margin:6px 0; }
@keyframes bob{ 0%,100%{transform:translateY(0) rotate(-3deg);} 50%{transform:translateY(-12px) 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; }
.btn{
border:none; border-radius:16px; 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 .06s, box-shadow .06s;
width:100%; margin:10px 0; display:flex; align-items:center; justify-content:center; gap:10px;
}
.btn:active{ transform:translateY(4px); box-shadow:0 1px 0 var(--green-d); }
.btn.big{ font-size:22px; 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 #d9a900;}
.b-purple{background:var(--purple); box-shadow:0 5px 0 #a568d6;}
.b-white{background:#fff; color:var(--gray-t); border:2px solid var(--gray); box-shadow:0 3px 0 var(--gray);}
.btn.small{ font-size:15px; padding:11px 14px; width:auto; margin:0; border-radius:14px; }
.top{ display:flex; align-items:center; gap:12px; margin:2px 0 16px; }
.exit{ background:none; border:none; color:var(--gray-t); font-size:26px; font-weight:900; cursor:pointer; line-height:1; padding:0 4px; }
.bar{ flex:1; height:16px; background:var(--gray); border-radius:999px; overflow:hidden; }
.bar>i{ display:block; height:100%; width:0; background:var(--green); border-radius:999px; transition:width .4s; }
.scoretag{ font-weight:900; color:var(--yellow); font-size:17px; white-space:nowrap; }
.timer{ height:12px; background:var(--gray); border-radius:999px; overflow:hidden; margin:4px 0 18px; }
.timer>i{ display:block; height:100%; width:100%; background:var(--green); border-radius:999px; }
.problem{ text-align:center; font-size:62px; font-weight:900; margin:14px 0 6px; letter-spacing:2px; }
.problem .q{ color:var(--blue); }
.kou-hint{ text-align:center; font-size:20px; color:var(--gray-t); min-height:26px; margin-bottom:8px; font-weight:800; }
.bubble{
background:#f7f7f7; border:2px solid var(--gray); border-radius:16px; padding:14px 16px; text-align:center;
font-size:22px; font-weight:800; min-height:56px; margin:10px 0; display:flex; align-items:center; justify-content:center;
color:var(--ink);
}
.bubble .dim{ color:var(--gray-t); font-weight:600; font-size:17px; }
.mic-wrap{ text-align:center; margin:6px 0; }
.mic{ width:104px; height:104px; border-radius:50%; border:none; cursor:pointer; font-size:48px; color:#fff;
background:var(--green); box-shadow:0 6px 0 var(--green-d); }
.mic:active{ transform:translateY(3px); box-shadow:0 3px 0 var(--green-d); }
@keyframes ripple{ 0%{box-shadow:0 6px 0 var(--blue-d), 0 0 0 0 rgba(28,176,246,.5);} 100%{box-shadow:0 6px 0 var(--blue-d), 0 0 0 30px rgba(28,176,246,0);} }
.mic.listening{ animation:ripple 1.1s infinite; background:var(--blue); }
@keyframes spin{ to{transform:rotate(360deg);} }
.mic.think{ background:var(--purple); box-shadow:0 6px 0 #a568d6; }
.mic-label{ font-size:15px; color:var(--gray-t); font-weight:800; margin-top:8px; }
.pad{ display:grid; grid-template-columns:repeat(3,1fr); gap:10px; margin-top:8px; }
.pad button{ font-family:inherit; font-size:26px; font-weight:800; padding:16px 0; border:2px solid var(--gray);
border-radius:14px; background:#fff; color:var(--ink); cursor:pointer; box-shadow:0 3px 0 var(--gray); }
.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:#d9a900; box-shadow:0 3px 0 #d9a900; }
.tools{ display:flex; gap:8px; margin-top:16px; justify-content:center; flex-wrap:wrap; }
.settings{ margin-top:18px; border:2px solid var(--gray); border-radius:16px; overflow:hidden; }
.settings summary{ cursor:pointer; padding:12px 16px; font-weight:800; color:var(--gray-t); list-style:none; }
.settings summary::-webkit-details-marker{ display:none; }
.settings .body{ padding:6px 16px 16px; }
.set-row{ display:flex; align-items:center; justify-content:space-between; padding:8px 0; font-weight:700; font-size:15px; color:#555; }
.set-row input[type=number]{ width:64px; font:inherit; font-weight:800; text-align:center; border:2px solid var(--gray); border-radius:10px; padding:6px; }
.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:2px; right:2px; width:44px; height:44px; border:none; background:none;
font-size:23px; color:var(--gray-t); cursor:pointer; opacity:.55; transition:transform .2s, opacity .2s; }
.gear:hover{ opacity:1; } .gear:active{ transform:rotate(45deg); }
/* 两个大入口 */
.entry{ flex-direction:column; gap:3px; padding:22px 18px; }
.entry .e-title{ font-size:24px; font-weight:900; }
.entry .e-sub{ font-size:13px; font-weight:700; opacity:.92; }
/* 设置弹窗 */
.modal{ position:fixed; inset:0; background:rgba(0,0,0,.38); z-index:70;
display:flex; align-items:center; justify-content:center; padding:20px; }
.modal-card{ background:#fff; width:100%; max-width:420px; border-radius:22px; padding:18px 20px;
box-shadow:0 20px 60px rgba(0,0,0,.28); 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:19px; 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:22px 20px calc(22px + env(safe-area-inset-bottom));
border-top-left-radius:22px; border-top-right-radius:22px; box-shadow:0 -6px 24px rgba(0,0,0,.08); }
.sheet.show{ transform:translateY(0); }
.sheet.ok{ background:#d7ffb8; } .sheet.no{ background:#ffdfe0; }
.sheet .head{ display:flex; align-items:center; gap:14px; }
.sheet .icon{ font-size:44px; }
.sheet .s-title{ font-size:24px; font-weight:900; }
.sheet.ok .s-title{ color:var(--green-d); } .sheet.no .s-title{ color:#ea2b2b; }
.sheet .s-sub{ font-size:16px; font-weight:800; margin-top:2px; }
.sheet.ok .s-sub{ color:var(--green-d); } .sheet.no .s-sub{ color:#ea2b2b; }
.sheet .cont{ margin-top:16px; width:100%; border:none; border-radius:16px; padding:15px; font-size:18px; font-weight:900; color:#fff; cursor:pointer; }
.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:44px; text-align:center; letter-spacing:6px; margin:10px 0; }
.score-big{ font-size:52px; text-align:center; font-weight:900; color:var(--yellow); }
.note{ font-size:12.5px; color:var(--gray-t); text-align:center; margin-top:16px; line-height:1.7; font-weight:600; }
</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>
<button class="btn b-green entry" onclick="startGame('seq')">
<span class="e-title">🐢 顺序闯关</span><span class="e-sub">1×1 到 9×9 按顺序背</span></button>
<button class="btn b-blue entry" onclick="startGame('rand')">
<span class="e-title">🎲 随机挑战</span><span class="e-sub">打乱抽题,错题多练</span></button>
<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>
<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>
<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="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=正常,越小越慢)
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:150, // 连续多久有声音算“开口了”
vadSilenceMs:1100, // 开口后静音多久算“说完了”(放宽以容忍背口诀“三七…二十一”中间的停顿)
maxAttempts:3, // 一题内没听清最多自动重录几次
noMicWindowMs:8000, // 关闭限时时的最长录音窗口
};
/* ============ 口诀数据 ============ */
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=[];
for(let a=1;a<=9;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};
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 judge(transcript,a,b,p){
if(!transcript) return null;
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; } }
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);
}
/* ============ 语音播放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,
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(){ return CONFIG.timerEnabled ? CONFIG.timeLimitMs : CONFIG.noMicWindowMs; }
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();
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(), win=autoWindowMs();
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)>=win){ 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){ resolve(false,true); }
else { heard.innerHTML='<span class="dim">没听到声音,再说一次~</span>'; setTimeout(()=>{ if(!answered) startRecordCycle(); },250); }
return;
}
micBtn.className='mic think'; micLabel.textContent='识别中…'; heard.innerHTML='🧠 识别中…';
try{
const r=await fetch('/stt',{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){ resolve(true,false); }
else if(isJustFactors(text,cur.a,cur.b)){ // 只背了口诀前半(因数) → 当作没说完,继续听
attempts++;
heard.innerHTML='👂 '+text+' …';
if(attempts>=CONFIG.maxAttempts){ resolve(false,false); }
else setTimeout(()=>{ if(!answered) startRecordCycle(); },250);
}
else if(text && hasNumber(text)){ resolve(false,false); } // 说了个数、但答错
else { // 没识别出数字 → 再给机会
attempts++;
if(attempts>=CONFIG.maxAttempts){ resolve(false,false); }
else setTimeout(()=>{ if(!answered) startRecordCycle(); },300);
}
}catch(e){
if(answered) return;
micBtn.className='mic'; heard.innerHTML='⚠️ 识别服务未连接';
attempts++;
if(attempts>=CONFIG.maxAttempts){ resolve(false,true); }
else setTimeout(()=>{ if(!answered) startRecordCycle(); },700);
}
}
async function autoAsk(){
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){ switchToPad(); return; }
if(recording){ stopRecord(); } // 说完了,立刻识别
else if(!answered){ autoAsk(); } // 再答一次
}
/* ============ 计时器 ============ */
let timerId=null, qStart=0;
function clearTimer(){ if(timerId){ clearInterval(timerId); timerId=null; } }
function startTimer(){
clearTimer(); qStart=Date.now();
if(!CONFIG.timerEnabled){ $('timerWrap').classList.add('hide'); return; }
$('timerWrap').classList.remove('hide');
const bar=$('timerBar'); bar.style.width='100%'; bar.style.background='var(--green)';
timerId=setInterval(()=>{
const el=Date.now()-qStart, left=Math.max(0,CONFIG.timeLimitMs-el), pct=left/CONFIG.timeLimitMs*100;
bar.style.width=pct+'%';
bar.style.background = pct<30?'var(--red)':(pct<60?'var(--yellow)':'var(--green)');
if(el>=CONFIG.timeLimitMs){ clearTimer(); if(!autoActive) resolve(false,true); } // 录音模式由 VAD 结束
},50);
}
/* ============ 游戏状态 ============ */
let mode='seq', problems=[], idx=0, score=0, cur=null, padMode=false, answered=false,
answeredCount=0, missCount=0, lastKey='', pendingAdv=null, autoActive=false;
const $=id=>document.getElementById(id);
const home=$('home'),game=$('game'),end=$('end');
const micBtn=$('micBtn'),micLabel=$('micLabel'),heard=$('heard'),
problem=$('problem'),kouHint=$('kouHint'),gMascot=$('gMascot');
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){
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'; setTimeout(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=setTimeout(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=''; resolve(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(){
clearTimer(); if(recording) stopRecord(); clearTimeout(pendingAdv);
speechSynthesis&&speechSynthesis.cancel(); $('sheet').classList.remove('show');
game.classList.add('hide'); end.classList.add('hide'); home.classList.remove('hide');
}
/* ============ 音效 ============ */
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'); }
/* ============ 首页提示(一行) ============ */
$('homeNote').innerHTML = canRecord
? '💡 点“开始”后允许麦克风即可全程免手'
: '💡 当前环境不支持录音,将用数字键盘作答';
</script>
</body>
</html>