feat: 双人抢答模式 + 识别优化 + PC/Pad 布局重设计
- 识别:按题动态 grammar(前缀锁定因数)、只输出中文数字、发音兼容别名(n/l 不分、四十↔十四混淆重录) - 新增双人抢答:键盘/蓝牙手柄绑定抢答、A 秒硬截止扣分、实时计分与冠军结算,支持顺序/随机出题 - 全局口诀范围设置(几开头~几开头,localStorage 持久化) - 修复退出比赛残留定时器弹结果卡的 bug(epoch 代际守卫);计时条改 rAF 逐帧驱动 - 新增 eval.js 端到端识别回归评测(135 例 100%) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
661
index.html
661
index.html
@@ -5,94 +5,128 @@
|
||||
<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:#58a700; --red:#ff4b4b; --red-d:#d63535;
|
||||
--blue:#1cb0f6; --blue-d:#1899d6; --yellow:#ffc800; --purple:#ce82ff;
|
||||
--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:"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;
|
||||
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: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; }
|
||||
.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: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);} }
|
||||
.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:16px; font-family:inherit; font-weight:800; letter-spacing:.5px;
|
||||
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 .06s, box-shadow .06s;
|
||||
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;
|
||||
}
|
||||
.btn:active{ transform:translateY(4px); box-shadow:0 1px 0 var(--green-d); }
|
||||
.btn.big{ font-size:22px; padding:20px; }
|
||||
@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 #d9a900;}
|
||||
.b-purple{background:var(--purple); box-shadow:0 5px 0 #a568d6;}
|
||||
.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:15px; padding:11px 14px; width:auto; margin:0; border-radius:14px; }
|
||||
.btn.small{ font-size:16px; padding:12px 18px; width:auto; margin:0; border-radius:15px; }
|
||||
|
||||
.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; }
|
||||
/* 首页:三张模式卡横排,错峰入场 */
|
||||
.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; }
|
||||
|
||||
.timer{ height:12px; background:var(--gray); border-radius:999px; overflow:hidden; margin:4px 0 18px; }
|
||||
.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:62px; font-weight:900; margin:14px 0 6px; letter-spacing:2px; }
|
||||
.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:20px; color:var(--gray-t); min-height:26px; margin-bottom:8px; font-weight:800; }
|
||||
.kou-hint{ text-align:center; font-size:24px; color:var(--gray-t); min-height:30px; 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);
|
||||
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:17px; }
|
||||
.bubble .dim{ color:var(--gray-t); font-weight:600; font-size:18px; }
|
||||
|
||||
.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-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 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);} }
|
||||
@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); }
|
||||
@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; }
|
||||
.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: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{ 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:#d9a900; box-shadow:0 3px 0 #d9a900; }
|
||||
.pad .del{ background:var(--yellow); color:#7a5b00; border-color:var(--yellow-d); box-shadow:0 3px 0 var(--yellow-d); }
|
||||
|
||||
.tools{ display:flex; gap:8px; margin-top:16px; justify-content:center; flex-wrap:wrap; }
|
||||
.tools{ display:flex; gap:10px; margin-top:18px; 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; }
|
||||
.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; }
|
||||
@@ -101,38 +135,35 @@
|
||||
.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; }
|
||||
.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(0,0,0,.38); z-index:70;
|
||||
.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:420px; border-radius:22px; padding:18px 20px;
|
||||
box-shadow:0 20px 60px rgba(0,0,0,.28); animation:mpop .22s ease-out; }
|
||||
.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:19px; color:var(--ink); }
|
||||
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: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); }
|
||||
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:14px; }
|
||||
.sheet .icon{ font-size:44px; }
|
||||
.sheet .s-title{ font-size:24px; font-weight:900; }
|
||||
.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:16px; font-weight:800; margin-top:2px; }
|
||||
.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-top:16px; width:100%; border:none; border-radius:16px; padding:15px; font-size:18px; font-weight:900; color:#fff; cursor:pointer; }
|
||||
.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); }
|
||||
@@ -145,9 +176,49 @@
|
||||
.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; }
|
||||
.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>
|
||||
@@ -159,10 +230,14 @@
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -170,6 +245,9 @@
|
||||
<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>
|
||||
@@ -180,6 +258,12 @@
|
||||
<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>
|
||||
@@ -220,6 +304,58 @@
|
||||
</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>
|
||||
@@ -246,6 +382,7 @@
|
||||
const CONFIG = {
|
||||
ttsQuestion:true, // 自动朗读题目
|
||||
readRate:0.6, // 读题语速(1=正常,越小越慢)
|
||||
tableMin:1, tableMax:9, // 口诀范围:只出「几开头」到「几开头」的口诀(全局,三种模式都生效)
|
||||
timerEnabled:true, // 限时作答
|
||||
timeLimitMs:6000, // 每题作答窗口(读题后开始计时/录音)
|
||||
sessionLength:20, // 随机模式一局题数
|
||||
@@ -256,10 +393,16 @@ const CONFIG = {
|
||||
seqTeachAfter:1, // 顺序模式错几次后自动念口诀
|
||||
// 录音 / 静音检测(VAD)
|
||||
vadThreshold:0.018, // 音量阈值(0-1),超过算“在说话”
|
||||
vadStartMs:150, // 连续多久有声音算“开口了”
|
||||
vadStartMs:100, // 连续多久有声音算“开口了”(太大会漏掉“一”这类短促单音节)
|
||||
vadSilenceMs:1100, // 开口后静音多久算“说完了”(放宽以容忍背口诀“三七…二十一”中间的停顿)
|
||||
maxAttempts:3, // 一题内没听清最多自动重录几次
|
||||
noMicWindowMs:8000, // 关闭限时时的最长录音窗口
|
||||
// 抢答模式
|
||||
matchBuzzMs:5000, // 出题后多久内可以抢答
|
||||
matchAnswerMs:6000, // 抢到后必须在几秒内作答(A 秒)
|
||||
matchQuestions:10, // 一局题数
|
||||
matchWinPts:5, // 答对得分
|
||||
matchLosePts:2, // 答错/超时扣分
|
||||
};
|
||||
|
||||
/* ============ 口诀数据 ============ */
|
||||
@@ -270,11 +413,14 @@ 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});
|
||||
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};
|
||||
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);
|
||||
@@ -291,13 +437,18 @@ function extractLastNumber(text){
|
||||
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,'')
|
||||
// 剥掉标点/口语填充词/运算词,再剥掉句首因数前缀(正反序、中/数字),只留“得数”部分
|
||||
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;
|
||||
@@ -314,6 +465,20 @@ function isJustFactors(transcript,a,b){
|
||||
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;
|
||||
@@ -334,7 +499,7 @@ function speakAsync(text,cancelFirst){
|
||||
|
||||
/* ============ 录音 + 静音检测 + 后端识别 ============ */
|
||||
let mediaStream=null, audioCtx=null, analyser=null, vadBuf=null;
|
||||
let mediaRecorder=null, recChunks=[], recording=false, recStart=0,
|
||||
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);
|
||||
|
||||
@@ -359,7 +524,15 @@ function pickMime(){
|
||||
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 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;
|
||||
@@ -371,7 +544,7 @@ function startRecordCycle(){
|
||||
mediaRecorder.onstop=onRecStop;
|
||||
recording=true;
|
||||
try{ mediaRecorder.start(); }catch(e){ recording=false; switchToPad(); return; }
|
||||
recStart=Date.now();
|
||||
recStart=Date.now(); recWindow=autoWindowMs(); // 窗口在开录时定死,录音中不再变
|
||||
micBtn.className='mic listening'; micLabel.textContent='我在听… 说答案!';
|
||||
heard.innerHTML='<span class="dim">🎙️ 请说答案…</span>';
|
||||
startTimer();
|
||||
@@ -381,11 +554,11 @@ 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();
|
||||
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)>=win){ stopRecord(); return; } // 到时间
|
||||
if((now-recStart)>=recWindow){ stopRecord(); return; } // 到时间
|
||||
vadRAF=requestAnimationFrame(vadTick);
|
||||
}
|
||||
function stopRecord(){
|
||||
@@ -400,75 +573,105 @@ async function onRecStop(){
|
||||
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); }
|
||||
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',{method:'POST',headers:{'Content-Type':blob.type||'application/octet-stream'},body:blob});
|
||||
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){ resolve(true,false); }
|
||||
if(verd===true){ settle(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);
|
||||
if(attempts>=CONFIG.maxAttempts || matchOvertime()){ settle(false,matchOvertime()); }
|
||||
else later(()=>{ if(!answered) startRecordCycle(); },250);
|
||||
}
|
||||
else if(text && hasNumber(text)){ resolve(false,false); } // 说了个数、但答错
|
||||
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){ resolve(false,false); }
|
||||
else setTimeout(()=>{ if(!answered) startRecordCycle(); },300);
|
||||
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){ resolve(false,true); }
|
||||
else setTimeout(()=>{ if(!answered) startRecordCycle(); },700);
|
||||
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){ switchToPad(); return; }
|
||||
if(recording){ stopRecord(); } // 说完了,立刻识别
|
||||
else if(!answered){ autoAsk(); } // 再答一次
|
||||
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){ clearInterval(timerId); timerId=null; } }
|
||||
function clearTimer(){ if(timerId){ cancelAnimationFrame(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;
|
||||
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>=CONFIG.timeLimitMs){ clearTimer(); if(!autoActive) resolve(false,true); } // 录音模式由 VAD 结束
|
||||
},50);
|
||||
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 micBtn=$('micBtn'),micLabel=$('micLabel'),heard=$('heard'),
|
||||
problem=$('problem'),kouHint=$('kouHint'),gMascot=$('gMascot');
|
||||
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; }
|
||||
@@ -484,7 +687,7 @@ function pickWeighted(){
|
||||
}
|
||||
|
||||
async function startGame(m){
|
||||
mode=m; score=0; idx=0; answeredCount=0; lastKey='';
|
||||
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;
|
||||
@@ -512,7 +715,7 @@ function renderQuestion(){
|
||||
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); }
|
||||
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(); }
|
||||
}
|
||||
|
||||
@@ -557,7 +760,7 @@ function afterSheet(type,title,sub,thenFn,delay){
|
||||
$('sheetIcon').textContent = type==='ok'?'🎉':'💡';
|
||||
$('sheetTitle').textContent=title; $('sheetSub').textContent=sub;
|
||||
const go=()=>{ s.classList.remove('show'); thenFn&&thenFn(); };
|
||||
clearTimeout(pendingAdv); pendingAdv=setTimeout(go,delay);
|
||||
clearTimeout(pendingAdv); pendingAdv=later(go,delay);
|
||||
$('contBtn').onclick=()=>{ clearTimeout(pendingAdv); go(); };
|
||||
}
|
||||
function positive(elapsed){
|
||||
@@ -593,7 +796,7 @@ function switchToMic(){
|
||||
}
|
||||
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 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%';
|
||||
@@ -607,9 +810,230 @@ function finish(){
|
||||
}
|
||||
function restart(){ startGame(mode); }
|
||||
function quit(){
|
||||
clearTimer(); if(recording) stopRecord(); clearTimeout(pendingAdv);
|
||||
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'); home.classList.remove('hide');
|
||||
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();
|
||||
}
|
||||
|
||||
/* ============ 音效 ============ */
|
||||
@@ -642,7 +1066,8 @@ function burst(n){ const fx=$('fx');
|
||||
function openSettings(){ $('settingsModal').classList.remove('hide'); }
|
||||
function closeSettings(){ $('settingsModal').classList.add('hide'); }
|
||||
|
||||
/* ============ 首页提示(一行) ============ */
|
||||
/* ============ 启动初始化 ============ */
|
||||
$('cfgRangeMin').value=CONFIG.tableMin; $('cfgRangeMax').value=CONFIG.tableMax; // 口诀范围回填(localStorage)
|
||||
$('homeNote').innerHTML = canRecord
|
||||
? '💡 点“开始”后允许麦克风即可全程免手'
|
||||
: '💡 当前环境不支持录音,将用数字键盘作答';
|
||||
|
||||
Reference in New Issue
Block a user