init: init proj

This commit is contained in:
Kid
2026-07-18 16:48:41 +08:00
commit d975859286
12 changed files with 1204 additions and 0 deletions

651
index.html Normal file
View File

@@ -0,0 +1,651 @@
<!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>