' +
'
' + repeatIcon(q.shape, q.total, 30) + '
' +
'
' +
'
' + repeatIcon(q.shape, q.part, 30) + '
' +
'
?
' +
'
';
},
L3: function (q) {
return '' +
'
' + repeatIcon(q.shape, q.a, 36) + '
' +
'
' + (q.op === 'add' ? '又来了' : '走掉了') + '
' +
'
' + repeatIcon(q.shape, q.b, 36) + '
' +
'
';
},
L4: function () { return ''; }
};
/* 把题库摊平并预计算题干 / 答案 / 讲解 / 云端文案。
解析期跑一次;内容包替换 MATH_BANK 后必须**再跑一次**(否则题源 MATH_FLAT
仍是旧快照,数学模块出不了新题)。push 的是 it 本身 —— 与 MATH_BANK 同一对象。 */
var MATH_FLAT = [];
function buildMathFlat() {
MATH_FLAT.length = 0;
var _eidx = 0; // 说明配音预录键索引:与 tools/texts-mimo-all.json 的 E_calc_ 对齐
['L1', 'L2', 'L3'].forEach(function (lv) {
(MATH_BANK[lv] || []).forEach(function (it) {
it.level = lv;
it.ekey = 'calc_' + _eidx++; // 游戏说明(加减题)预录音频键(小爱)
var name = shapeName(it.shape);
if (lv === 'L1') {
it.ans = it.n;
it.maxNum = it.n;
it.text = '数一数,有几个' + name + '?';
it.content = '数数(L1 实物点数):' + name + ' ' + it.n + ' 个';
it.explain = '一个一个数,' + name + '一共有 ' + it.n + ' 个。';
} else if (lv === 'L2') {
it.ans = it.total - it.part;
it.maxNum = it.total;
it.text = '把 ' + it.total + ' 个' + name + '分成两堆,左边 ' + it.part + ' 个,右边有几个?';
it.content = '数的分解(L2):' + it.total + ' 分成 ' + it.part + ' 和 ' + it.ans;
it.explain = it.total + ' 可以分成 ' + it.part + ' 和 ' + it.ans + ',所以右边是 ' + it.ans + ' 个。';
} else {
it.ans = it.op === 'add' ? it.a + it.b : it.a - it.b;
it.maxNum = Math.max(it.a, it.b, it.ans);
it.text = it.q;
it.content = '情境加减(L3):' + it.q;
it.explain = '原来有 ' + it.a + ' ' + it.unit + ',' +
(it.op === 'add'
? '又来了 ' + it.b + ' ' + it.unit + ',一共是 '
: '走掉了 ' + it.b + ' ' + it.unit + ',还剩 ') +
it.ans + ' ' + it.unit + '。';
}
MATH_FLAT.push(it);
});
});
}
buildMathFlat(); // 解析期首次摊平
/**
* 现算一道 L4 符号算式(仅 5-6 岁档)。
* @param {{numRange:number[]}} lv 当前数学档位参数
* @returns {Object} 题目对象
*/
function makeSymbolQuestion(lv) {
var nr = (lv && lv.numRange) || [1, 10];
var hi = nr[1] || 10;
var isAdd = Math.random() > 0.4;
var a, b, ans, text;
if (isAdd) {
a = Math.floor(Math.random() * (hi - 1)) + 1;
b = Math.floor(Math.random() * Math.max(1, hi - a)) + 1;
ans = a + b;
text = a + ' + ' + b + ' = ?';
} else {
a = Math.floor(Math.random() * Math.max(1, hi - 2)) + 3;
b = Math.floor(Math.random() * Math.max(1, a - 1)) + 1;
ans = a - b;
text = a + ' - ' + b + ' = ?';
}
return {
level: 'L4',
text: text,
ans: ans,
maxNum: Math.max(a, b, ans),
content: '符号算式(L4):' + text,
explain: (isAdd ? a + ' 加 ' + b : a + ' 减 ' + b) + ' 等于 ' + ans + '。'
};
}
/* 现算题(L4 符号算式)的「近期已出」去重表。
makeSymbolQuestion 本身是纯随机的:内容包把题库换小、或某档位池子被抽空时,
会退化成「一直随机」→ 连着出重复的题,违背 C09「15 题无重复」。
去重表与抽题袋同生命周期,resetDrawBags() 时一并清空。 */
var _symbolSeen = {};
function makeSymbolQuestionOnce(lv) {
var q = null;
for (var t = 0; t < 40; t++) {
q = makeSymbolQuestion(lv);
if (!q) return null;
if (!_symbolSeen[q.text]) { _symbolSeen[q.text] = 1; return q; }
}
// 数字范围太小、组合被抽尽 → 清空去重表重来,保证永远有题可出
_symbolSeen = {};
if (q) _symbolSeen[q.text] = 1;
return q;
}
/**
* 把一道题渲染到 #calcQuestion / #calcVisual / #calcOptions,并接入 QuizEngine。
* @param {Object} q 题目对象(含 level / text / ans / content / explain)
* @param {Object} lv 当前数学档位参数
*/
function renderCalcQuestion(q, lv) {
var optCount = lv.optCount || 4;
var nr = lv.numRange || [1, 10];
var lo = nr[0] || 1;
var hi = Math.max(nr[1] || 10, optCount + 2);
calcAnswer = q.ans;
var qEl = document.getElementById('calcQuestion');
if (qEl) qEl.textContent = q.text;
var vEl = document.getElementById('calcVisual');
if (vEl) vEl.innerHTML = (MATH_RENDER[q.level] || MATH_RENDER.L4)(q);
var arr = makeOptions(q.ans, optCount, lo, hi);
var container = document.getElementById('calcOptions');
container.innerHTML = '';
var btns = arr.map(function (n) {
var btn = document.createElement('button');
btn.className = 'calc-btn';
btn.textContent = n;
container.appendChild(btn);
return btn;
});
QuizEngine.create({
options: btns,
values: arr,
answer: q.ans,
star: 1,
content: q.content,
explain: q.explain,
gentle: ['再看看图,数一数', '我们去掉一个不对的,再试试'],
praise: { first: '答对啦!', retry: '答对了!' },
explainBox: document.querySelector('#num-calc .calc-area'),
onDone: function (res, el) {
if (res === 'giveup') return;
var r = el.getBoundingClientRect();
showStarAnimation(r.x, r.y);
speakFixed('F:0', '答对了', 'zh-CN');
}
});
// 问题3:题目出现即朗读题干(小朋友不识字);仅在该游戏面板可见时读
if (isVisibleNow(document.querySelector('#num-calc'))) {
stopSpeak();
speak(q.text + ',答案是多少?', 'zh-CN', q.ekey ? 'E:' + q.ekey : null);
}
var cpb = document.getElementById('calcPrevBtn');
if (cpb) cpb.disabled = !_histLen('calc');
}
/* 答题点 2/7 —— 加减练习(C06 降龄版)。
注意:原代码 L1952 的「已答过就不再响应」判断查的是 correct/wrong 两个 class,
而下面只改了 style、从没加过 class —— 那句是死代码,所以也能无限刷分(C02)。
现在改由 QuizEngine 统一锁定。 */
function generateCalcQuestion() {
var lv = gradeParam('math');
var pool = filterByGrade(MATH_FLAT, 'math');
var q;
// 只有 5-6 岁档(symbol:true)才有概率抽到符号算式
if (lv.symbol && Math.random() < 0.45) {
q = makeSymbolQuestionOnce(lv);
} else if (pool.length) {
var i = drawIdx('math', pool.length);
if (i < 0) { resetDrawBags(); i = drawIdx('math', pool.length); }
q = pool[i];
} else {
// 池子为空(内容包把该档位的题换没了)→ 现算,且必须去重,否则会连出重复题
q = makeSymbolQuestionOnce(lv);
}
if (!q) return;
_pushQ('calc', q);
renderCalcQuestion(q, lv);
}
function prevCalcQuestion() {
var q = _popQ('calc');
if (!q) return;
renderCalcQuestion(q, gradeParam('math'));
}
generateCalcQuestion();
/* 数数游戏 —— 图形素材(原 emoji 数组,C28 改为内联 SVG 图形库) */
var COUNT_SHAPES = ['apple', 'star', 'cat', 'butterfly', 'blossom', 'dog', 'cake', 'balloon', 'fish', 'sunflower'];
/* 答题点 3/7 —— 数数游戏。原代码没有任何拦截,同一按钮点一次 +1 星(C01)。 */
function renderCount(data) {
var shape = data.shape, countAnswer = data.countAnswer, arr = data.arr;
var box = document.getElementById('countShapes');
box.innerHTML = '';
for (var i = 0; i < countAnswer; i++) {
var sp = document.createElement('span');
sp.innerHTML = svgIcon(shape, 40);
box.appendChild(sp);
}
var container = document.getElementById('countOptions');
container.innerHTML = '';
var btns = arr.map(function (n) {
var btn = document.createElement('button');
btn.className = 'count-btn';
btn.textContent = n;
container.appendChild(btn);
return btn;
});
QuizEngine.create({
options: btns,
values: arr,
answer: countAnswer,
star: 1,
content: '数一数:' + countAnswer + ' 个',
explain: '一个一个数,一共是 ' + countAnswer + ' 个' + shapeName(shape) + '。',
gentle: ['再数一遍,一共有几个呢', '我们去掉一个不对的,再数数看'],
praise: { first: '数对了!', retry: '答对了!' },
explainBox: document.querySelector('#num-count .count-area'),
onDone: function (res, el) {
if (res === 'giveup') return;
var r = el.getBoundingClientRect();
showStarAnimation(r.x, r.y);
speakFixed('F:0', '答对了', 'zh-CN');
}
});
// 问题3:题目出现即朗读题干(小朋友不识字);仅在该游戏面板可见时读
if (isVisibleNow(document.querySelector('#num-count'))) {
stopSpeak();
speak('数一数,一共有几个' + shapeName(shape) + '?', 'zh-CN', 'E:count_' + shape);
}
var cpb = document.getElementById('countPrevBtn');
if (cpb) cpb.disabled = !_histLen('count');
}
function generateCountQuestion() {
var shape = pickOne(COUNT_SHAPES);
// 2-9 个,走抽题袋:连玩 8 题数字不重样(C09「15 题无重复」的一部分)
var cd = drawIdx('count', 8);
if (cd < 0) { resetDrawBags(); cd = drawIdx('count', 8); }
countAnswer = cd + 2;
var opts = [countAnswer];
var guard = 0;
while (opts.length < 4 && guard++ < 200) {
var w = countAnswer + Math.floor(Math.random() * 5) - 2;
if (w < 1) w = countAnswer + 1;
if (w !== countAnswer && opts.indexOf(w) === -1) opts.push(w);
}
var arr = shuffle(opts);
_pushQ('count', { shape: shape, countAnswer: countAnswer, arr: arr });
renderCount({ shape: shape, countAnswer: countAnswer, arr: arr });
}
function prevCountQuestion() {
var d = _popQ('count');
if (!d) return;
renderCount(d);
}
generateCountQuestion();
// ========== 模块4: 逻辑挑战 ==========
/* 找规律题库(原 emoji 序列,C28 改为内联 SVG 图形) */
var ICON_NEXT = '';
var ICON_PREV = '';
var ICON_RESET = '';
/* 通用「上一题」历史栈(问题2:每个游戏可回看上一题,不重新随机)。
存的是「生成题目用的种子/对象」,prevX 用同一 render 重渲染,保证与当时完全一致。
(_qHist 已在字母配对段之前统一声明,避免加载期被重新赋值清空历史) */
function _pushQ(name, q) { (_qHist[name] = _qHist[name] || []).push(q); }
function _popQ(name) { var h = _qHist[name]; return h && h.length ? h.pop() : null; }
function _histLen(name) { return (_qHist[name] || []).length; }
function _prevBtnHtml(name, cls) {
return '';
}
/* C09:找规律题库 8 → 30 题。
① 统一带 minGrade(3-4 岁只出「两个轮流来」的 AB 型,5-6 岁才出 ABC / 大数递增);
② 统一带 options(干扰项),实际出几个由 GRADE_PARAM.logic.optCount 截断;
③ seq 只存完整周期,展示时按 patternLen 从**尾部**截,保证截短后答案依然成立。 */
var PATTERN_SETS = [
{ seq: ['dotRed','dotBlue','dotRed','dotBlue','dotRed'], answer: 'dotBlue', options: ['dotBlue','dotRed','dotGreen','dotYellow'], label: '颜色规律:红蓝红蓝', explain: '红、蓝、红、蓝……一个隔一个,所以下一个是蓝圆点。', minGrade: 'G34' },
{ seq: ['star','star','moon','star','star'], answer: 'moon', options: ['star','moon','sun','rainbow'], label: '图形规律:星星月亮', explain: '两颗星星后面跟着一个月亮,所以下一个是月亮。', minGrade: 'G34' },
{ seq: ['apple','pear','apple','pear','apple'], answer: 'pear', options: ['apple','pear','grape','orange'], label: '水果规律:苹果梨', explain: '苹果、梨、苹果、梨……两个水果轮流来,所以下一个是梨。', minGrade: 'G34' },
{ seq: ['triangle','square','triangle','square','triangle'], answer: 'square', options: ['triangle','square','circle','starOutline'], label: '形状规律:三角方形', explain: '三角形、方形轮流来,所以下一个是方形。', minGrade: 'G34' },
{ seq: ['cat','dog','cat','dog','cat'], answer: 'dog', options: ['cat','dog','rabbit','frog'], label: '动物规律:猫狗', explain: '小猫、小狗轮流来,所以下一个是小狗。', minGrade: 'G34' },
{ seq: ['blossom','daisy','blossom','daisy','blossom'], answer: 'daisy', options: ['blossom','daisy','rose','sunflower'], label: '花朵规律:粉花白花', explain: '粉花、白花轮流来,所以下一个是白花。', minGrade: 'G34' },
{ seq: ['1','2','3','4','5'], answer: '6', options: ['6','7','4','8'], label: '数字规律:1到6', explain: '1、2、3、4、5 一个比一个大 1,所以下一个是 6。', minGrade: 'G34' },
{ seq: ['sun','moon','sun','moon','sun'], answer: 'moon', options: ['moon','sun','star','rainbow'], label: '天空规律:太阳月亮', explain: '太阳、月亮轮流来,所以下一个是月亮。', minGrade: 'G34' },
{ seq: ['cake','balloon','cake','balloon','cake'], answer: 'balloon', options: ['balloon','cake','bow','lollipop'], label: '物品规律:蛋糕气球', explain: '蛋糕、气球轮流来,所以下一个是气球。', minGrade: 'G34' },
{ seq: ['fish','frog','fish','frog','fish'], answer: 'frog', options: ['frog','fish','rabbit','unicorn'], label: '动物规律:小鱼青蛙', explain: '小鱼、青蛙轮流来,所以下一个是青蛙。', minGrade: 'G34' },
{ seq: ['dotYellow','dotYellow','dotBlue','dotYellow','dotYellow'], answer: 'dotBlue', options: ['dotYellow','dotBlue','dotRed','dotGreen'], label: '颜色规律:黄黄蓝', explain: '黄、黄、蓝、黄、黄……两个黄后面是一个蓝,所以下一个是蓝圆点。', minGrade: 'G45' },
{ seq: ['dotRed','dotBlue','dotBlue','dotRed','dotBlue'], answer: 'dotBlue', options: ['dotRed','dotBlue','dotGreen','dotYellow'], label: '颜色规律:红蓝蓝', explain: '红、蓝、蓝、红、蓝……一个红后面是两个蓝,所以下一个是蓝圆点。', minGrade: 'G45' },
{ seq: ['circle','square','triangle','circle','square'], answer: 'triangle', options: ['triangle','circle','square','starOutline'], label: '形状规律:圆方三角', explain: '圆、方、三角,三个一组轮流来,所以下一个是三角形。', minGrade: 'G45' },
{ seq: ['cat','dog','rabbit','cat','dog'], answer: 'rabbit', options: ['rabbit','cat','dog','frog'], label: '动物规律:猫狗兔', explain: '小猫、小狗、兔子,三个一组轮流来,所以下一个是兔子。', minGrade: 'G45' },
{ seq: ['2','4','6','8'], answer: '10', options: ['10','9','12','7'], label: '数字规律:双数', explain: '2、4、6、8 每次多 2,所以下一个是 10。', minGrade: 'G45' },
{ seq: ['10','9','8','7'], answer: '6', options: ['6','5','8','4'], label: '数字规律:倒着数', explain: '10、9、8、7 每次少 1,所以下一个是 6。', minGrade: 'G45' },
{ seq: ['rainbow','star','rainbow','star','rainbow'], answer: 'star', options: ['star','rainbow','moon','sun'], label: '天空规律:彩虹星星', explain: '彩虹、星星轮流来,所以下一个是星星。', minGrade: 'G45' },
{ seq: ['bow','lollipop','bow','lollipop','bow'], answer: 'lollipop', options: ['lollipop','bow','dice','teddy'], label: '物品规律:蝴蝶结棒棒糖', explain: '蝴蝶结、棒棒糖轮流来,所以下一个是棒棒糖。', minGrade: 'G45' },
{ seq: ['teddy','target','teddy','target','teddy'], answer: 'target', options: ['target','teddy','dice','palette'], label: '玩具规律:小熊靶子', explain: '小熊、靶子轮流来,所以下一个是靶子。', minGrade: 'G45' },
{ seq: ['dice','palette','dice','palette','dice'], answer: 'palette', options: ['palette','dice','teddy','target'], label: '玩具规律:骰子调色盘', explain: '骰子、调色盘轮流来,所以下一个是调色盘。', minGrade: 'G45' },
{ seq: ['rose','sunflower','rose','sunflower','rose'], answer: 'sunflower', options: ['sunflower','rose','daisy','hibiscus'], label: '花朵规律:玫瑰向日葵', explain: '玫瑰、向日葵轮流来,所以下一个是向日葵。', minGrade: 'G45' },
{ seq: ['unicorn','rabbit','unicorn','rabbit','unicorn'], answer: 'rabbit', options: ['rabbit','unicorn','cat','frog'], label: '动物规律:独角兽兔子', explain: '独角兽、兔子轮流来,所以下一个是兔子。', minGrade: 'G45' },
{ seq: ['egg','hat','egg','hat','egg'], answer: 'hat', options: ['hat','egg','nest','crown'], label: '物品规律:鸡蛋帽子', explain: '鸡蛋、帽子轮流来,所以下一个是帽子。', minGrade: 'G45' },
{ seq: ['icecream','juice','icecream','juice','icecream'], answer: 'juice', options: ['juice','icecream','cake','egg'], label: '食物规律:冰淇淋果汁', explain: '冰淇淋、果汁轮流来,所以下一个是果汁。', minGrade: 'G45' },
{ seq: ['kite','nest','kite','nest','kite'], answer: 'nest', options: ['nest','kite','crown','van'], label: '物品规律:风筝鸟窝', explain: '风筝、鸟窝轮流来,所以下一个是鸟窝。', minGrade: 'G45' },
{ seq: ['crown','van','crown','van','crown'], answer: 'van', options: ['van','crown','kite','nest'], label: '物品规律:皇冠小货车', explain: '皇冠、小货车轮流来,所以下一个是小货车。', minGrade: 'G45' },
{ seq: ['5','10','15'], answer: '20', options: ['20','16','25','18'], label: '数字规律:五个五个数', explain: '5、10、15 每次多 5,所以下一个是 20。', minGrade: 'G56' },
{ seq: ['watermelon','xylophone','watermelon','xylophone','watermelon'], answer: 'xylophone', options: ['xylophone','watermelon','van','yoyo'], label: '物品规律:西瓜木琴', explain: '西瓜、木琴轮流来,所以下一个是木琴。', minGrade: 'G56' },
{ seq: ['yoyo','zebra','yoyo','zebra','yoyo'], answer: 'zebra', options: ['zebra','yoyo','hat','kite'], label: '物品规律:溜溜球斑马', explain: '溜溜球、斑马轮流来,所以下一个是斑马。', minGrade: 'G56' },
{ seq: ['dotRed','dotBlue','dotGreen','dotYellow','dotRed'], answer: 'dotBlue', options: ['dotBlue','dotRed','dotGreen','dotYellow'], label: '颜色规律:红蓝绿黄', explain: '红、蓝、绿、黄四个一组轮流来,黄后面又回到红,所以下一个是蓝圆点。', minGrade: 'G56' }
];
/* 答题点 4/7 —— 找规律(C07 分档 + C09 抽题袋)*/
function renderPattern(p, lp) {
var card = document.getElementById('patternCard');
// 展示长度按档位截断(从尾部截,保证截短后答案依然成立)
var showLen = Math.min(lp.patternLen || 5, p.seq.length);
var seq = p.seq.slice(-showLen);
// 选项个数按档位截断,且正确答案一定在里头
var optCount = lp.optCount || 4;
var raw = [p.answer];
p.options.forEach(function (o) {
if (raw.length < optCount && raw.indexOf(o) === -1) raw.push(o);
});
var opts = shuffle(raw);
card.innerHTML =
'' + svgIcon('think', 20) + '找出下一个是什么?
' +
'' +
seq.map(function (s) { return shapeHTML(s, 40); }).join('') +
'' + svgIcon('question', 34) + '' +
'
' +
'' + opts.map(function (o) {
return '';
}).join('') + '
' +
_prevBtnHtml('pattern') +
'';
var btns = Array.prototype.slice.call(card.querySelectorAll('.logic-btn'));
QuizEngine.create({
options: btns,
values: opts.slice(),
answer: p.answer,
star: 1,
content: '找规律:' + p.label,
explain: p.explain,
gentle: ['再看看前面是怎么排的', '我们去掉一个不对的,再想想'],
praise: { first: '太聪明了!', retry: '答对了!' },
explainBox: card,
onDone: function (res, el) {
if (res === 'giveup') return;
var r = el.getBoundingClientRect();
showStarAnimation(r.x, r.y);
speakFixed('F:0', '答对了', 'zh-CN');
}
});
// 问题3:题目出现即朗读题干(小朋友不识字);仅在该游戏面板可见时读
if (isVisibleNow(card)) {
stopSpeak();
speak('看看前面是怎么排的,找出下一个是什么?', 'zh-CN', 'E:static_pattern');
}
}
function generatePattern() {
var lp = gradeParam('logic');
var pool = filterByGrade(PATTERN_SETS, 'logic');
var pi = drawIdx('pattern', pool.length);
if (pi < 0) { resetDrawBags(); pi = drawIdx('pattern', pool.length); }
var p = pool[pi] || PATTERN_SETS[0];
_pushQ('pattern', p);
renderPattern(p, lp);
}
function prevPattern() {
var p = _popQ('pattern');
if (!p) return;
renderPattern(p, gradeParam('logic'));
}
generatePattern();
/* 图形配对素材(原 emoji 数组)。C09:10 → 30 个,配合抽题袋做到连玩不重样。 */
var PAIR_SHAPES = [
'rainbow', 'bow', 'unicorn', 'lollipop', 'dice', 'teddy', 'target', 'palette', 'butterfly', 'hibiscus',
'apple', 'pear', 'grape', 'orange', 'cat', 'dog', 'rabbit', 'frog', 'fish', 'blossom',
'daisy', 'sunflower', 'rose', 'cake', 'balloon', 'star', 'moon', 'sun', 'egg', 'hat'
];
/* 答题点 5/7 —— 图形配对(C07 分档 + C09 抽题袋)*/
function renderPair(data, lp) {
var card = document.getElementById('pairCard');
var target = data.target, arr = data.arr;
card.innerHTML =
'' + svgIcon('eye', 20) + '找出和上面一样的图案
' +
'' + shapeHTML(target, 64) + '
' +
'' + arr.map(function (o) {
return '';
}).join('') + '
' +
_prevBtnHtml('pair') +
'';
var btns = Array.prototype.slice.call(card.querySelectorAll('.logic-btn'));
QuizEngine.create({
options: btns,
values: arr.slice(),
answer: target,
star: 1,
content: '图形配对:' + shapeName(target),
explain: '上面那个是' + shapeName(target) + ',找到和它一模一样的那个。',
gentle: ['再仔细看看上面的图案', '我们去掉一个不一样的,再找找'],
praise: { first: '配对成功!', retry: '答对了!' },
explainBox: card,
onDone: function (res, el) {
if (res === 'giveup') return;
var r = el.getBoundingClientRect();
showStarAnimation(r.x, r.y);
speakFixed('F:0', '答对了', 'zh-CN');
}
});
// 问题3:题目出现即朗读题干(小朋友不识字);仅在该游戏面板可见时读
if (isVisibleNow(card)) {
stopSpeak();
speak('找出和上面一样的图案', 'zh-CN', 'E:static_pair');
}
}
function generatePair() {
var lp = gradeParam('logic');
var optCount = lp.optCount || 4;
var ti = drawIdx('pair', PAIR_SHAPES.length);
if (ti < 0) { resetDrawBags(); ti = drawIdx('pair', PAIR_SHAPES.length); }
var target = PAIR_SHAPES[ti] || PAIR_SHAPES[0];
// 干扰项从剩下的图形里取,保证 4 个选项互不相同
var rest = shuffle(PAIR_SHAPES.filter(function (s) { return s !== target; }));
var opts = [target];
for (var ri = 0; ri < rest.length && opts.length < optCount; ri++) opts.push(rest[ri]);
var arr = shuffle(opts);
_pushQ('pair', { target: target, arr: arr });
renderPair({ target: target, arr: arr }, lp);
}
function prevPair() {
var d = _popQ('pair');
if (!d) return;
renderPair(d, gradeParam('logic'));
}
generatePair();
/* 答题点 6/7 —— 排序题。
这是唯一的多步题(要按从小到大依次点 4 个数字),引擎用 mode:'sequence' 处理:
点错不锁按钮(每个数字最终都要被点到),只微抖 + 计数;
② 用「高亮下一个该点的数字」代替「排除干扰项」;③ 直接给出完整顺序。 */
/* 排序题是「现算」的(组合数上千,没必要存固定题库),
所以除了抽题袋还要额外记住最近出过的组合,才能保证连玩 15 题不重样。 */
var _recentSort = {};
var _recentSortCount = 0;
/**
* 抽一组互不相同的数字并升序排好。
* @param {number} len 个数(来自 GRADE_PARAM.logic.sortLen)
* @param {number} hi 取值上界
* @returns {number[]} 升序数组
*/
function drawSortSet(len, hi) {
var used = {};
var out = [];
var guard = 0;
while (out.length < len && guard++ < 300) {
var d = drawIdx('sort', hi);
if (d < 0) { resetDrawBags(); d = drawIdx('sort', hi); }
var n = d + 1;
if (!used[n]) { used[n] = 1; out.push(n); }
}
// 兜底:袋子抽干就随机补齐,保证一定凑够 len 个
while (out.length < len) {
var r = Math.floor(Math.random() * hi) + 1;
if (!used[r]) { used[r] = 1; out.push(r); }
}
out.sort(function (a, b) { return a - b; });
return out;
}
/** 记住这组组合(超过 40 条就清空重计,避免无限增长)。 */
function rememberSort(key) {
if (_recentSortCount > 40) { _recentSort = {}; _recentSortCount = 0; }
if (!_recentSort[key]) { _recentSort[key] = 1; _recentSortCount++; }
}
function renderSort(data) {
var card = document.getElementById('sortCard');
var sorted = data.sorted, shuffled = data.shuffled;
card.innerHTML =
'' + svgIcon('ruler', 20) + '按从小到大的顺序点击数字
' +
'' + shuffled.map(function (v) {
return '' + v + '';
}).join('') + '
' +
'已选:
' +
'' + shuffled.map(function (v) {
return '';
}).join('') + '
' +
_prevBtnHtml('sort') +
'' +
'';
var btns = Array.prototype.slice.call(card.querySelectorAll('.sort-pick'));
var resultEl = card.querySelector('#sortResult');
var session = QuizEngine.create({
options: btns,
values: shuffled.slice(),
answer: sorted,
mode: 'sequence',
star: 2,
content: '数字排序:' + sorted.join('→'),
explain: '从小到大排,正确顺序是 ' + sorted.join(' → ') + '。',
gentle: ['从小到大,先点最小的那个', '先看一看最小的数字是几'],
praise: { first: '排对了!', retry: '排对了!' },
explainBox: card,
onPick: function (sel) {
if (resultEl) resultEl.textContent = '已选:' + sel.join(' → ');
},
onDone: function (res) {
if (res === 'giveup') return;
showStarAnimation();
speakFixed('F:0', '答对了', 'zh-CN');
}
});
window.resetSort = function () {
// 本题已结算就不能再重选(否则可以反复拿星);此时直接换一题
if (!session.reset()) generateSort();
};
// 问题3:题目出现即朗读题干(小朋友不识字);仅在该游戏面板可见时读
if (isVisibleNow(card)) {
stopSpeak();
speak('按从小到大的顺序,点一点儿数字', 'zh-CN', 'E:static_sort');
}
}
function generateSort() {
var lp = gradeParam('logic');
var len = lp.sortLen || 4;
// numSort=false(3-4 岁)只排一位数 1-9:数字小、一眼看得过来
// numSort=true(4 岁以上)放开到 1-15
var hi = lp.numSort ? 15 : 9;
// 最多试 40 次拿一组没出过的组合;实在拿不到就接受重复(组合数很少时才可能)
var sorted = null;
for (var att = 0; att < 40 && !sorted; att++) {
var cand = drawSortSet(len, hi);
if (!_recentSort[cand.join(',')]) { sorted = cand; break; }
}
if (!sorted) sorted = drawSortSet(len, hi);
rememberSort(sorted.join(','));
var shuffled = shuffle(sorted);
_pushQ('sort', { sorted: sorted, shuffled: shuffled });
renderSort({ sorted: sorted, shuffled: shuffled });
}
function prevSort() {
var d = _popQ('sort');
if (!d) return;
renderSort(d);
}
generateSort();
// ========== 模块5: 闯关冒险 ==========
/* 闯关状态已在 [S0] 统一上提声明 */
// 古诗题库(ans 存「正确答案的值」而不是下标 —— 因为选项会被洗牌)
/* P1-6 年龄适配:4 岁不识字,移除「作者 / 出处」型(5 题),换成
看图选词 / 数一数 / 接下一句 —— 全部可凭图形或记忆作答,不依赖识字。
图形只能复用已有内联 SVG 图形 key(SVG_SHAPES),零新增资源。
字段结构不变:{ q, opts, ans, why },可选 shapes(图形行)。 */
var POEM_QUIZ = [
{ q: '图上是什么小动物?', shapes: ['cat'], opts: ['小猫','小狗','兔子','小鱼'], ans: '小猫', why: '图上是一只小猫。' },
{ q: '"举头望明月"的下一句是?', opts: ['低头思故乡','疑是地上霜','处处闻啼鸟','白日依山尽'], ans: '低头思故乡', why: '"举头望明月,低头思故乡。"' },
{ q: '数一数,一共几个?', shapes: ['apple','apple','apple'], opts: ['3','2','4','5'], ans: '3', why: '一共有 3 个苹果。' },
{ q: '"白毛浮绿水"写的是什么动物?', opts: ['鹅','鸭','鹤','天鹅'], ans: '鹅', why: '这首诗写的就是大白鹅。' },
{ q: '"床前明月光"下一句是?', opts: ['疑是地上霜','举头望明月','低头思故乡','处处闻啼鸟'], ans: '疑是地上霜', why: '"床前明月光,疑是地上霜。"' },
{ q: '图上是什么?', shapes: ['fish'], opts: ['小鱼','小猫','小狗','蝴蝶'], ans: '小鱼', why: '图上是小鱼。' },
{ q: '"欲穷千里目"下一句是?', opts: ['更上一层楼','黄河入海流','白日依山尽','入海流'], ans: '更上一层楼', why: '"欲穷千里目,更上一层楼。"' },
{ q: '"小荷才露尖尖角"的下一句是?', opts: ['早有蜻蜓立上头','处处闻啼鸟','疑是地上霜','更上一层楼'], ans: '早有蜻蜓立上头', why: '"小荷才露尖尖角,早有蜻蜓立上头。"' }
];
/* C09:闯关逻辑小题库 6 → 30 题,统一带 minGrade。
注意: C10:这里**不许出现 + - =**,一律用「合起来 / 拿走 / 还剩」的中文说法,
4-5 岁档的闯关题全部走这一套。 */
var LOGIC_QUIZ = [
{ q: '下面接着应该是?', shapes: ['dotRed','dotBlue','dotRed','dotBlue','dotRed'], opts: ['dotBlue','dotRed','dotGreen','dotYellow'], ans: 'dotBlue', why: '红、蓝、红、蓝……一个隔一个,所以下一个是蓝圆点。', minGrade: 'G34' },
{ q: '1, 2, 3, 4, 下一个是?', opts: ['5','6','4','3'], ans: '5', why: '一个比一个大 1,所以 4 后面是 5。', minGrade: 'G34' },
{ q: '下面接着应该是?', shapes: ['apple','pear','apple','pear'], opts: ['apple','pear','grape','orange'], ans: 'apple', why: '苹果、梨轮流来,梨后面轮到苹果。', minGrade: 'G34' },
{ q: '哪个最大? 3, 7, 1, 5', opts: ['7','5','3','1'], ans: '7', why: '3、7、1、5 里面 7 最大。', minGrade: 'G34' },
{ q: '哪个最小? 8, 2, 6, 4', opts: ['2','4','6','8'], ans: '2', why: '8、2、6、4 里面 2 最小。', minGrade: 'G34' },
{ q: '下面接着应该是?', shapes: ['star','moon','star','moon'], opts: ['star','moon','sun','rainbow'], ans: 'star', why: '星星、月亮轮流来,月亮后面轮到星星。', minGrade: 'G34' },
{ q: '2 和 5,哪个更大?', opts: ['5','2','3','4'], ans: '5', why: '5 比 2 大,所以选 5。', minGrade: 'G34' },
{ q: '9 和 4,哪个更小?', opts: ['4','9','5','6'], ans: '4', why: '4 比 9 小,所以选 4。', minGrade: 'G34' },
{ q: '下面接着应该是?', shapes: ['sun','moon','sun','moon'], opts: ['sun','moon','star','rainbow'], ans: 'sun', why: '太阳、月亮轮流来,月亮后面轮到太阳。', minGrade: 'G34' },
{ q: '3 个和 1 个合起来是几个?', opts: ['4','3','5','2'], ans: '4', why: '3 个再加 1 个,一共是 4 个。', minGrade: 'G34' },
{ q: '下面接着应该是?', shapes: ['dotYellow','dotYellow','dotBlue','dotYellow','dotYellow'], opts: ['dotYellow','dotBlue','dotRed','dotGreen'], ans: 'dotBlue', why: '黄、黄、蓝……两个黄后面是一个蓝,所以下一个是蓝圆点。', minGrade: 'G45' },
{ q: '2, 4, 6, 8, 下一个是?', opts: ['10','9','12','7'], ans: '10', why: '每次多 2,8 后面是 10。', minGrade: 'G45' },
{ q: '10, 9, 8, 7, 下一个是?', opts: ['6','5','8','4'], ans: '6', why: '每次少 1,7 后面是 6。', minGrade: 'G45' },
{ q: '下面接着应该是?', shapes: ['circle','square','triangle','circle','square'], opts: ['triangle','circle','square','starOutline'], ans: 'triangle', why: '圆、方、三角三个一组,方后面是三角。', minGrade: 'G45' },
{ q: '下面接着应该是?', shapes: ['cat','dog','rabbit','cat','dog'], opts: ['rabbit','cat','dog','frog'], ans: 'rabbit', why: '猫、狗、兔三个一组,狗后面是兔子。', minGrade: 'G45' },
{ q: '哪个最大? 12, 5, 9, 7', opts: ['12','9','7','5'], ans: '12', why: '12、5、9、7 里面 12 最大。', minGrade: 'G45' },
{ q: '哪个最小? 11, 3, 8, 6', opts: ['3','6','8','11'], ans: '3', why: '11、3、8、6 里面 3 最小。', minGrade: 'G45' },
{ q: '5 个和 3 个合起来是几个?', opts: ['8','7','9','6'], ans: '8', why: '5 个再加 3 个,一共是 8 个。', minGrade: 'G45' },
{ q: '10 个拿走 4 个,还剩几个?', opts: ['6','5','7','4'], ans: '6', why: '10 个去掉 4 个,还剩 6 个。', minGrade: 'G45' },
{ q: '下面接着应该是?', shapes: ['rainbow','star','rainbow','star','rainbow'], opts: ['star','rainbow','moon','sun'], ans: 'star', why: '彩虹、星星轮流来,所以下一个是星星。', minGrade: 'G45' },
{ q: '下面接着应该是?', shapes: ['cake','balloon','cake','balloon','cake'], opts: ['balloon','cake','bow','lollipop'], ans: 'balloon', why: '蛋糕、气球轮流来,所以下一个是气球。', minGrade: 'G45' },
{ q: '7 个和 2 个合起来是几个?', opts: ['9','8','10','7'], ans: '9', why: '7 个再加 2 个,一共是 9 个。', minGrade: 'G45' },
{ q: '5, 10, 15, 下一个是?', opts: ['20','16','25','18'], ans: '20', why: '每次多 5,15 后面是 20。', minGrade: 'G56' },
{ q: '下面接着应该是?', shapes: ['dotRed','dotBlue','dotGreen','dotYellow','dotRed'], opts: ['dotBlue','dotRed','dotGreen','dotYellow'], ans: 'dotBlue', why: '红、蓝、绿、黄四个一组,黄后面回到红,所以下一个是蓝圆点。', minGrade: 'G56' },
{ q: '下面接着应该是?', shapes: ['watermelon','xylophone','watermelon','xylophone','watermelon'], opts: ['xylophone','watermelon','van','yoyo'], ans: 'xylophone', why: '西瓜、木琴轮流来,所以下一个是木琴。', minGrade: 'G56' },
{ q: '下面接着应该是?', shapes: ['yoyo','zebra','yoyo','zebra','yoyo'], opts: ['zebra','yoyo','hat','kite'], ans: 'zebra', why: '溜溜球、斑马轮流来,所以下一个是斑马。', minGrade: 'G56' },
{ q: '哪个最大? 19, 12, 20, 15', opts: ['20','19','15','12'], ans: '20', why: '19、12、20、15 里面 20 最大。', minGrade: 'G56' },
{ q: '哪个最小? 14, 6, 11, 9', opts: ['6','9','11','14'], ans: '6', why: '14、6、11、9 里面 6 最小。', minGrade: 'G56' },
{ q: '6 个和 9 个合起来是几个?', opts: ['15','14','16','13'], ans: '15', why: '6 个再加 9 个,一共是 15 个。', minGrade: 'G56' },
{ q: '20 个拿走 8 个,还剩几个?', opts: ['12','11','13','10'], ans: '12', why: '20 个去掉 8 个,还剩 12 个。', minGrade: 'G56' }
];
/**
* 题库生成器 —— 从各模块随机抽题。
* @returns {{text:string, shapes:Array|null, options:Array, answer:string, explain:string}}
*/
function generateAdventureQuestion() {
var type = pickOne(['letter', 'number', 'poem', 'logic']);
var question = { text: '', shapes: null, options: [], answer: '', explain: '' };
if (type === 'letter') {
var idx = Math.floor(Math.random() * 26);
var upper = String.fromCharCode(65 + idx);
var lower = String.fromCharCode(97 + idx);
var opts = [lower];
var g1 = 0;
while (opts.length < 4 && g1++ < 200) {
var r = String.fromCharCode(97 + Math.floor(Math.random() * 26));
if (opts.indexOf(r) === -1) opts.push(r);
}
question.text = '大写字母 "' + upper + '" 的小写是?';
question.options = shuffle(opts);
question.answer = lower;
question.explain = '大写 ' + upper + ' 的小写是 ' + lower + '。';
} else if (type === 'number') {
/* C10:闯关数字题降龄 —— 走和「加减练习」同一套题库(MATH_FLAT),
不再自己写 `a + b = ?`。4-5 岁档只可能抽到 L1/L2/L3(全是图 + 中文),
符号算式要 GRADE_PARAM.math.symbol 为 true(5-6 岁)才可能出现。
约定不变:answer 存「值」不存下标(选项会被洗牌)。 */
var mlv = gradeParam('math');
var mpool = filterByGrade(MATH_FLAT, 'math');
var mq;
if (mlv.symbol && Math.random() < 0.45) {
mq = makeSymbolQuestion(mlv);
} else if (mpool.length) {
var mi = drawIdx('advMath', mpool.length);
if (mi < 0) { resetDrawBags(); mi = drawIdx('advMath', mpool.length); }
mq = mpool[mi];
} else {
mq = makeSymbolQuestion(mlv);
}
var nlo = (mlv.numRange && mlv.numRange[0]) || 1;
var nhi = Math.max((mlv.numRange && mlv.numRange[1]) || 10, mq.maxNum || 10, 6);
question.text = mq.text;
question.options = makeOptions(mq.ans, 4, nlo, nhi).map(String);
question.answer = String(mq.ans);
question.explain = mq.explain;
} else if (type === 'poem') {
var pq = pickOne(POEM_QUIZ);
question.text = pq.q;
question.shapes = pq.shapes || null; // P1-6:看图选词 / 数一数题带图形行
question.options = shuffle(pq.opts);
question.answer = pq.ans;
question.explain = pq.why;
} else {
var lpool = filterByGrade(LOGIC_QUIZ, 'logic');
var li = drawIdx('advLogic', lpool.length);
if (li < 0) { resetDrawBags(); li = drawIdx('advLogic', lpool.length); }
var lq = lpool[li] || LOGIC_QUIZ[0];
question.text = lq.q;
question.shapes = lq.shapes || null;
question.options = shuffle(lq.opts);
question.answer = lq.ans;
question.explain = lq.why;
}
return question;
}
function startAdventure() {
advQuestion = 0;
advCorrectCount = 0;
advActive = true;
currentModule = '闯关冒险';
document.getElementById('advStartBtn').style.display = 'none';
document.getElementById('advLevelInfo').textContent = `第 ${advLevel} 关`;
showNextAdvQuestion();
}
function showNextAdvQuestion() {
advQuestion++;
if (advQuestion > 5) {
// 本关结束
finishLevel();
return;
}
document.getElementById('advProgress').textContent = `第 ${advQuestion}/5 题`;
document.getElementById('advProgressBar').style.width = ((advQuestion - 1) / 5 * 100) + '%';
var q = generateAdventureQuestion();
/* 题干:带图形的题(找规律)在文字下方渲染一行 SVG + 问号 */
var qTextEl = document.getElementById('advQText');
if (q.shapes) {
qTextEl.innerHTML = '';
var label = document.createElement('div');
label.textContent = q.text;
qTextEl.appendChild(label);
var row = document.createElement('div');
row.className = 'shape-row';
row.innerHTML = q.shapes.map(function (s) { return shapeHTML(s, 36); }).join('') +
'' + svgIcon('question', 32) + '';
qTextEl.appendChild(row);
} else {
qTextEl.textContent = q.text;
}
var container = document.getElementById('advOptions');
container.innerHTML = '';
var btns = q.options.map(function (opt) {
var btn = document.createElement('button');
btn.className = 'adv-btn';
btn.innerHTML = shapeHTML(opt, 30);
container.appendChild(btn);
return btn;
});
/* 答题点 7/7 —— 闯关冒险。原代码只设了 pointerEvents='none',
且依赖 idx === q.answer 的下标比较;现在统一交给 QuizEngine。 */
QuizEngine.create({
options: btns,
values: q.options.slice(),
answer: q.answer,
star: 1,
content: q.text,
explain: q.explain,
gentle: ['再看看别的选项吧', '我们去掉一个不对的,再试试'],
praise: { first: '正确!', retry: '答对了!' },
explainBox: document.getElementById('advQuestion'),
onDone: function (res, el) {
if (res !== 'giveup') {
advCorrectCount++;
var r = el.getBoundingClientRect();
showStarAnimation(r.x, r.y);
}
// 1.5 秒后进入下一题
setTimeout(showNextAdvQuestion, 1500);
}
});
}
function finishLevel() {
document.getElementById('advProgressBar').style.width = '100%';
const passed = advCorrectCount >= 3; // 答对3题以上过关
if (passed) {
if (!SNAPSHOT_MODE) { // C16b:快照版不推进关卡 / 不写记录
levels++;
advLevel++;
setData('levels', levels);
setData('advLevel', advLevel);
// 通关记录写入云端,星星随这条记录一起记(不重复写)
logRecord('关卡进度', '闯关冒险', '第 ' + (advLevel - 1) + ' 关', '完成', 3);
addStars(3, true); // 过关额外奖励
// 检查是否获得徽章
checkBadges();
}
document.getElementById('advQText').textContent = '恭喜通关!答对 ' + advCorrectCount + '/5 题!';
document.getElementById('advOptions').innerHTML = '';
document.getElementById('advStartBtn').innerHTML = '下一关 ' + ICON_ROCKET;
document.getElementById('advStartBtn').style.display = '';
showToast('太棒了,过关啦!', 1600, 'trophy');
} else {
document.getElementById('advQText').textContent = '答对 ' + advCorrectCount + '/5 题,需要答对3题才能过关,再试一次吧!';
document.getElementById('advOptions').innerHTML = '';
document.getElementById('advStartBtn').innerHTML = '重新挑战 ' + ICON_AGAIN;
document.getElementById('advStartBtn').style.display = '';
showToast('加油,再来一次!', 1600, 'cheer');
}
document.getElementById('advLevelInfo').textContent = `第 ${advLevel} 关`;
advActive = false;
updateStarDisplay();
renderBadges();
}
// ========== [S11] 成就徽章系统 ==========
const allBadges = [
{ id: 'first_star', name: '第一颗星', icon: '', condition: () => stars >= 1 },
{ id: 'ten_stars', name: '十星达人', icon: '', condition: () => stars >= 10 },
{ id: 'fifty_stars', name: '五十星王者', icon: '', condition: () => stars >= 50 },
{ id: 'hundred_stars', name: '百星传说', icon: '', condition: () => stars >= 100 },
{ id: 'first_level', name: '初次闯关', icon: '', condition: () => levels >= 1 },
{ id: 'five_levels', name: '闯关达人', icon: '', condition: () => levels >= 5 },
{ id: 'ten_levels', name: '闯关大师', icon: '', condition: () => levels >= 10 },
{ id: 'ten_correct', name: '答题小能手', icon: '', condition: () => totalCorrect >= 10 },
{ id: 'fifty_correct', name: '学霸宝贝', icon: '', condition: () => totalCorrect >= 50 },
{ id: 'hundred_correct', name: '知识超人', icon: '', condition: () => totalCorrect >= 100 },
];
function checkBadges() {
if (SNAPSHOT_MODE) return; // C16b:快照版不新增徽章
let newBadge = false;
allBadges.forEach(b => {
if (!badges.includes(b.id) && b.condition()) {
badges.push(b.id);
newBadge = true;
// 新徽章同步到云端,其它设备打开就能看到
logRecord('成就徽章', '闯关冒险', b.id, '完成', 0);
}
});
if (newBadge) {
setData('badges', badges);
updateStarDisplay();
}
}
function renderBadges() {
const grid = document.getElementById('badgeGrid');
if (!grid) return;
grid.setAttribute('data-sp-bindable', 'database');
grid.setAttribute('data-sp-database-id', DATABASE_ID);
grid.innerHTML = '';
allBadges.forEach(b => {
const earned = badges.includes(b.id);
const div = document.createElement('div');
div.className = 'badge-item ' + (earned ? 'earned' : 'locked');
div.innerHTML = `${b.icon}
${b.name}
`;
grid.appendChild(div);
});
}
// ========== [S12] 国际化(i18n)==========
const I18N = {
zh: {
title: '宝贝学习乐园',
home: '首页', alphabet: '字母乐园', poem: '汉字花园',
number: '数字王国', logic: '逻辑挑战', adventure: '闯关冒险',
alphabetTab: '字母', poemTab: '汉字', numberTab: '数字',
logicTab: '逻辑', adventureTab: '闯关',
welcome: '欢迎回来,小宝贝!',
encouragement: '宝贝今天也要加油哦!',
statStars: '获得星星', statLevels: '通过关卡',
statBadges: '获得徽章', statQuestions: '答对题目',
sectionAlphabet: '字母乐园', sectionPoem: '汉字花园',
sectionNumber: '数字王国', sectionLogic: '逻辑挑战',
sectionAdventure: '闯关冒险', badgeWall: '成就墙',
letterCards: '字母卡片', matchGame: '配对游戏',
numCards: '数字认知', numCalc: '加减练习', numCount: '数数游戏',
logicPattern: '找规律', logicPair: '图形配对', logicSort: '排序题',
matchHint: '找出大写字母对应的小写字母',
poemHint: '点一点汉字、词语和俗语,就能听到读音',
calcHint: '看看图片,想一想',
countHint: '数一数,下面有几个?',
/* C08 家长区文案 */
parentZone: '家长区',
gradeLabel: '孩子年龄档位',
gradeNote: '档位只存在这台设备上,不会上传到云端。',
modAlphabet: '字母乐园', modAlphabetDesc: '26个字母认读与配对',
modPoem: '汉字花园', modPoemDesc: '单字 词语 俗语 古诗',
modNumber: '数字王国', modNumberDesc: '加减法与数数游戏',
modLogic: '逻辑挑战', modLogicDesc: '找规律配对排序',
modAdventure: '闯关冒险', modAdventureDesc: '综合闯关赢星星'
},
en: {
title: 'Baby Learning Playground',
home: 'Home', alphabet: 'Alphabet', poem: 'Hanzi',
number: 'Numbers', logic: 'Logic', adventure: 'Adventure',
alphabetTab: 'ABC', poemTab: 'Hanzi', numberTab: 'Nums',
logicTab: 'Logic', adventureTab: 'Quest',
welcome: 'Welcome back, little one!',
encouragement: 'Keep learning today!',
statStars: 'Stars Earned', statLevels: 'Levels Passed',
statBadges: 'Badges Won', statQuestions: 'Correct Answers',
sectionAlphabet: 'Alphabet Park', sectionPoem: 'Poem Garden',
sectionNumber: 'Number Kingdom', sectionLogic: 'Logic Challenge',
sectionAdventure: 'Adventure Quest', badgeWall: 'Badge Wall',
letterCards: 'Letter Cards', matchGame: 'Match Game',
numCards: 'Number Cards', numCalc: 'Add & Subtract', numCount: 'Counting',
logicPattern: 'Patterns', logicPair: 'Shape Match', logicSort: 'Sorting',
matchHint: 'Match the uppercase letter with its lowercase',
poemHint: 'Swipe to see more poems, tap Read to hear it',
calcHint: 'Work it out — what is the answer?',
countHint: 'Count them — how many are there?',
modAlphabet: 'Alphabet Park', modAlphabetDesc: '26 letters with sound & matching',
modPoem: 'Poem Garden', modPoemDesc: 'Classic poems with pinyin',
modNumber: 'Number Kingdom', modNumberDesc: 'Arithmetic & counting games',
modLogic: 'Logic Challenge', modLogicDesc: 'Patterns, matching & sorting',
modAdventure: 'Adventure Quest', modAdventureDesc: 'Mixed quizzes & star rewards'
}
};
/* C30:i18n 去索引。
此前靠「DOM 顺序 === 数组顺序」对应(导航 / 底部 Tab / 统计标签 / 模块标题 共 3 处),
只要在导航里插一项,后面所有文案整体错位一位,而且不会报错、只会显示错。
现在改成按 data-i18n 属性映射:元素自己声明要哪个 key,顺序完全无关。
若元素内含图标,把图标包一层 .i18n-icon,重设 innerHTML 时图标会被保留。 */
function applyI18N(lang) {
const t = I18N[lang] || I18N.zh;
document.documentElement.lang = lang === 'en' ? 'en' : 'zh-CN';
document.title = t.title;
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
const val = t[key];
if (!val) return;
const icon = el.querySelector('.i18n-icon');
if (icon) {
el.innerHTML = icon.outerHTML + ' ' + val;
} else {
el.textContent = val;
}
});
}
/* ============================================================
===== [S13B] 家长区与手动切档(C08)=====
------------------------------------------------------------
本期只做「UI + 存储 + 生效」三件事:
① 家长区默认收起,孩子在首页看不到内容
② 3 档单选,选完写本地 localStorage(key: wb_baby_grade),不上云
③ 切档后立刻重画所有受档位影响的模块
注意: 进入家长区 / 切档 / 改昵称 / 清空 / 导入恢复,这 5 处都要过
requireParentAuth()(C12 算术家长锁)。备份导出**不锁**(只读,不危险)。
============================================================ */
/** 展开 / 收起家长区。展开需先过家长锁。 */
function toggleParentZone() {
var zone = document.getElementById('parentZone');
var body = document.getElementById('pzBody');
if (!zone || !body) return;
var open = !zone.classList.contains('open');
if (open) {
requireParentAuth(function () {
zone.classList.add('open');
body.style.display = '';
renderGradePicker();
renderClearedInfo();
updateSyncState();
});
} else {
zone.classList.remove('open');
body.style.display = 'none';
}
}
/* ------------------------------------------------------------
C12 家长锁(算术题)
定位(务必照抄进任何说明里):**防误触,不防黑客**。
它拦的是「孩子顺手点进去把档位 / 数据改了」,不是攻击者。
DATABASE_ID 就明文写在页面里,真正的防护是「不把可写链接给外人」
(分享用快照版对在线表格零写入)。别在任何地方宣称它「安全」。
设计要点:
· 面向成年人 → 用两位数加法,**不做降龄**(4 岁孩子做不出来才是对的)
· 通过后 5 分钟内同类操作免重复出题(_authUntil 会话窗口,不落盘)
· 连错 3 次锁 30 秒,挡住「让娃乱点碰运气」
------------------------------------------------------------ */
var AUTH_WINDOW_MS = 5 * 60 * 1000; // 通过后 5 分钟内免重复
var LOCK_MAX_FAIL = 3; // 连错上限
var LOCK_COOLDOWN_MS = 30 * 1000; // 触发上限后的冷却
/**
* 执行一个需要家长权限的动作。
* @param {Function} callback 校验通过后执行
* @returns {boolean} 是否当场通过(免锁窗口内恒为 true)
*/
function requireParentAuth(callback) {
var now = Date.now();
if (now < _authUntil) { // 会话窗口内直接放行
if (typeof callback === 'function') callback();
return true;
}
// 已有一次待验证动作(锁弹窗还开着):拒绝新请求,避免「后触发的动作劫持先触发的」。
// 先触发的那次验证通过后照常执行;新请求请等弹窗结束后再来。
if (_pendingAuth) {
showToast('请先完成当前这次验证', 1600, 'think');
return false;
}
_pendingAuth = (typeof callback === 'function') ? callback : null;
openLockModal();
return false;
}
/** 出一道两位数加法(面向家长,故意不降龄)。答案与 4 个干扰项都不重复。 */
function makeLockChallenge() {
var a = 12 + Math.floor(Math.random() * 18); // 12–29
var b = 13 + Math.floor(Math.random() * 26); // 13–38
var ans = a + b;
var opts = [ans];
var guard = 0;
while (opts.length < 4 && guard++ < 200) {
var cand = ans + (Math.floor(Math.random() * 21) - 10); // ±10
if (cand !== ans && cand > 0 && opts.indexOf(cand) === -1) opts.push(cand);
}
for (var i = opts.length - 1; i > 0; i--) { // 洗牌
var j = Math.floor(Math.random() * (i + 1));
var t = opts[i]; opts[i] = opts[j]; opts[j] = t;
}
return { text: a + ' + ' + b + ' = ?', answer: ans, options: opts };
}
/** 打开家长锁弹窗(或显示冷却提示)。 */
function openLockModal() {
var mask = document.getElementById('lockMask');
if (!mask) { // 兜底:页面结构缺失时(降级 / 测试)不阻断,直接放行
if (_pendingAuth) { var cb = _pendingAuth; _pendingAuth = null; _authUntil = Date.now() + AUTH_WINDOW_MS; cb(); }
return;
}
var err = document.getElementById('lockErr');
if (err) err.textContent = '';
if (Date.now() < _lockBlockedUntil) {
var left = Math.ceil((_lockBlockedUntil - Date.now()) / 1000);
if (err) err.textContent = '试太多次啦,请等 ' + left + ' 秒再试。';
mask.hidden = false;
renderLockOptions(null);
return;
}
var c = makeLockChallenge();
_lockAnswer = c.answer;
var q = document.getElementById('lockQuestion');
if (q) q.textContent = c.text;
renderLockOptions(c.options);
mask.hidden = false;
}
function renderLockOptions(options) {
var host = document.getElementById('lockOptions');
if (!host) return;
host.innerHTML = '';
if (!options) return;
options.forEach(function (val) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'lock-opt';
btn.textContent = String(val);
btn.addEventListener('click', function () { submitLockAnswer(val); });
host.appendChild(btn);
});
}
function submitLockAnswer(val) {
var err = document.getElementById('lockErr');
if (val === _lockAnswer) {
_lockFail = 0;
_authUntil = Date.now() + AUTH_WINDOW_MS; // 开一个会话窗口
closeLockModal();
var cb = _pendingAuth; _pendingAuth = null;
if (typeof cb === 'function') cb();
return;
}
_lockFail++;
if (_lockFail >= LOCK_MAX_FAIL) {
_lockBlockedUntil = Date.now() + LOCK_COOLDOWN_MS;
_lockFail = 0;
openLockModal(); // 重画并显示冷却提示
return;
}
if (err) err.textContent = '不对哦,再算一次。';
var c = makeLockChallenge();
_lockAnswer = c.answer;
var q = document.getElementById('lockQuestion');
if (q) q.textContent = c.text;
renderLockOptions(c.options);
}
/** 关闭家长锁弹窗。 */
function closeLockModal() {
var mask = document.getElementById('lockMask');
if (mask) mask.hidden = true;
var err = document.getElementById('lockErr');
if (err) err.textContent = '';
}
/** 取消家长锁:丢弃待执行动作。 */
function cancelLock() {
_pendingAuth = null;
closeLockModal();
}
/** 渲染 3 档单选按钮,当前档高亮。 */
function renderGradePicker() {
var host = document.getElementById('gradePicker');
if (!host) return;
host.innerHTML = '';
Object.keys(GRADES).forEach(function (k) {
var g = GRADES[k];
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'grade-opt' + (k === currentGrade ? ' on' : '');
btn.textContent = g.label;
btn.setAttribute('aria-pressed', k === currentGrade ? 'true' : 'false');
btn.addEventListener('click', function () { setGrade(k); });
host.appendChild(btn);
});
updateGradeSummary();
updateGradePreview();
renderGradeBadge();
}
/** 当前档位的可读参数摘要(家长切档时当场看到"档位到底改了什么")。纯读取 GRADE_PARAM,不触发任何生成。 */
function gradeSummary(key) {
var gp = GRADE_PARAM;
var m = gp.math[key] || gp.math.G45;
var p = gp.poem[key] || gp.poem.G45;
var a = gp.alphabet[key] || gp.alphabet.G45;
var math = '数学 ' + m.numRange[0] + '–' + m.numRange[1] + (m.symbol ? ' · 含符号式' : ' · 不出符号式');
var poem = '古诗 ' + p.forms.join('/') + ' 最多 ' + p.maxLines + ' 行' + (p.fillBlank ? ' · 含填空' : '');
var alpha = '字母 ' + (a.mode === 'letterName' ? '认字母名' : '自然拼读') + (a.tracing ? ' · 描红' : '');
return math + ' · ' + poem + ' · ' + alpha;
}
/** 把当前档位摘要写进家长区 #gradeSummary。 */
function updateGradeSummary() {
var el = document.getElementById('gradeSummary');
if (el) el.textContent = gradeSummary(currentGrade);
}
/** 当前档的一道真实示例题(家长切档时当场看到"这档出的题长啥样")。纯读取题库:不改 currentGrade、不动抽题袋、不渲染到模块页。 */
function previewSample() {
var tier = currentGrade;
var lines = [];
var flat = (typeof MATH_FLAT !== 'undefined') ? MATH_FLAT : [];
// 优先抽真实情境题(L3 带题干),没有就退到 L1 实物点数造句
var story = flat.filter(function (q) { return isAvailable(q, 'math') && q.q; });
if (story.length) {
var s = story[Math.floor(Math.random() * story.length)];
lines.push('数学:' + s.q);
} else {
var l1 = flat.filter(function (q) { return isAvailable(q, 'math'); });
if (l1.length) {
var it = l1[Math.floor(Math.random() * l1.length)];
var nm = (typeof shapeName === 'function') ? shapeName(it.shape) : '图形';
lines.push('数学:数一数,有几个' + nm + '?');
}
}
// 5–6 岁档才有的符号式,单独补一条示例
var mp = GRADE_PARAM.math[tier] || GRADE_PARAM.math.G45;
if (mp.symbol) {
var hi = mp.numRange[1];
var x = 1 + Math.floor(Math.random() * hi), y = 1 + Math.floor(Math.random() * hi);
lines.push('数学(符号式):' + x + ' + ' + y + ' = ?');
}
var ap = GRADE_PARAM.alphabet[tier] || GRADE_PARAM.alphabet.G45;
lines.push('字母:' + (ap.mode === 'letterName' ? '认字母名字' : '自然拼读跟读') + (ap.tracing ? ' · 描红' : ''));
return lines.join(' • ');
}
/** 把当前档示例写进家长区 #gradePreview。renderGradePicker 与「试一题」按钮都会调用;按钮会重新随机一题。 */
function updateGradePreview() {
var el = document.getElementById('gradePreview');
if (el) el.textContent = previewSample();
}
/** 首页顶部「年龄档位胶囊」:写入当前档位文字(切档后实时刷新)。 */
function renderGradeBadge() {
var el = document.getElementById('gradePillText');
if (el) el.textContent = (GRADES[currentGrade] || GRADES.G45).label;
}
/** 首页欢迎语 + 鼓励语:用宝贝名称替换(名称未设时 getBabyName() 回落「小宝贝」,行为与改前一致)。 */
function renderWelcome() {
var name = getBabyName();
var w = document.getElementById('welcomeText');
if (w) w.textContent = '欢迎回来,' + name + '!';
var e = document.getElementById('encourageText');
if (e) e.textContent = name + '今天也要加油哦!';
}
/**
* 切换年龄档并立刻生效。
* @param {string} newGrade GRADES 的 key
*/
function setGrade(newGrade) {
if (!GRADES[newGrade]) return;
if (newGrade === currentGrade) { renderGradePicker(); return; }
// C12:切档是 5 个锁点之一(会话窗口内不重复出题)
requireParentAuth(function () { applyGradeChange(newGrade); });
}
/** 真正执行切档(家长锁通过后调用)。 */
function applyGradeChange(newGrade) {
if (!GRADES[newGrade]) return;
currentGrade = newGrade;
// C16b:快照(只读)版只改内存态、不落盘,守住「零写入」
if (!SNAPSHOT_MODE) setData('grade', newGrade);
resetDrawBags(); // 题库规模变了,旧抽题袋必须作废
applyGrade(); // 立刻重画,家长能当场看到变化
showToast('已切到 ' + GRADES[newGrade].label + ',去任意模块玩就能看到新题目', 2200, 'sparkle');
renderGradePicker();
renderGradeBadge();
}
/** 切档后重画所有受档位影响的模块(数学 / 逻辑 / 汉字 / 字母)。 */
function applyGrade() {
if (typeof generateCalcQuestion === 'function') generateCalcQuestion();
if (typeof generateCountQuestion === 'function') generateCountQuestion();
if (typeof generatePattern === 'function') generatePattern();
if (typeof generatePair === 'function') generatePair();
if (typeof generateSort === 'function') generateSort();
if (typeof renderPoemCarousel === 'function') renderPoemCarousel();
if (typeof renderLetterCards === 'function') renderLetterCards();
// 汉字二级菜单按档位递进显隐(D3),与题库重画同批生效
if (typeof applyHanziTier === 'function') applyHanziTier();
}
/** 内容字典被替换后的统一重渲染入口。
设计约束:渲染函数之间不互调,需要联动时一律走这里(本函数只负责「聚合调用」)。 */
function refreshAll() {
if (typeof document === 'undefined' || !document || !document.getElementById) return;
resetDrawBags(); // 题库规模可能变了,旧抽题袋必须作废
applyGrade(); // 数学 / 逻辑 / 古诗 / 字母 重画
refreshViews(); // 星星 / 徽章 / 今日任务 / 同步态
}
/* ------------------------------------------------------------
[PLATFORM:content] T-PACK-UI:外部内容包 → 内容字典
------------------------------------------------------------
目标:有 content/ 时优先用外部内容包;没有时行为与内置完全一致。
流程(严格按设计,不阻塞首屏):
内置内容立即渲染(模块级早已完成)
→ ContentPack.load()(异步、静默)
→ 拉 manifest.json(cache:'no-cache',其余带 ?v=version)
→ 版本与内置不同才拉 questions / poems / svg
→ 原地替换内容字典(引用不变 → 不动任何函数签名/闭包)
→ refreshAll() 重渲染
失败(无 fetch / 404 / 网络错 / 解析错)→ 一律 console.warn + 保持内置;
**绝不弹 toast、绝不进降级提示** —— 资料库版(平台内打开)根本没有 content/,必须与今天一模一样。
------------------------------------------------------------ */
var ContentPack = (function () {
var SEED_VERSION = 0; // 内置种子内容视为 version 0
var appliedVersion = null; // 已应用的外部包版本
var BASE = 'content/';
/* 方案 C 音频双轨:包音频(满质量)优先,内联种子(降质)兜底。
audioMap: key → 可播放 URL('content/assets/tts/L_A.m4a' 形态)。
audioLoaded 保证「TTL 内只拉一次」,且**首屏不拉**(只在首次需要播放时触发)。 */
var audioMap = {};
var audioLoaded = false;
/** 把 questions.json 的 items 还原成源码里的各原始题库(payload 为原样搬运的原始条目)。 */
function rebuildBanks(items) {
var math = { L1: [], L2: [], L3: [], L4: [] };
var logic = [], pattern = [], poemQuiz = [], alphabet = [];
var hanzi = { char: [], w2: [], w3: [], w4: [], saying: [] };
items.forEach(function (it) {
var p = it && it.payload;
if (!p) return;
if (it.subject === 'math') {
var lvl = String(it.id || '').split('.')[1];
if (lvl && math[lvl]) math[lvl].push(p);
} else if (it.subject === 'logic') logic.push(p);
else if (it.subject === 'pattern') pattern.push(p);
else if (it.subject === 'poem') poemQuiz.push(p);
else if (it.subject === 'english') alphabet.push(p);
else if (it.subject === 'hanzi') {
// id 形如 hanzi.char.01 —— 按 group 归位,交给 HANZI_BANK 原地替换
var hg = String(it.id || '').split('.')[1];
if (hg && hanzi[hg]) hanzi[hg].push(p);
}
});
return { math: math, logic: logic, pattern: pattern, poemQuiz: poemQuiz,
alphabet: alphabet, hanzi: hanzi };
}
/** 原地替换:数组改内容、对象改键值,**保持引用不变**(所有闭包/签名都不受影响)。 */
function replaceInPlace(target, src) {
if (!target || !src) return;
if (Array.isArray(target) && Array.isArray(src)) {
target.length = 0;
for (var i = 0; i < src.length; i++) target.push(src[i]);
} else if (!Array.isArray(target) && !Array.isArray(src)) {
Object.keys(target).forEach(function (k) { delete target[k]; });
Object.keys(src).forEach(function (k) { target[k] = src[k]; });
}
}
/** 古诗结构校验:整包有效才替换(任何一首结构异常 → 整包作废,保留内置,避免「只换一半」)。 */
function poemsValid(list) {
if (!Array.isArray(list) || !list.length) return false;
for (var i = 0; i < list.length; i++) {
var p = list[i];
if (!p || !Array.isArray(p.lines)) return false;
for (var j = 0; j < p.lines.length; j++) {
if (!Array.isArray(p.lines[j])) return false; // 行必须是数组(逐字 {ch,py})
}
}
return true;
}
/** SVG 校验:非空,且每个值都是非空字符串。 */
function svgValid(obj) {
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return false;
var keys = Object.keys(obj);
if (!keys.length) return false;
for (var i = 0; i < keys.length; i++) {
if (typeof obj[keys[i]] !== 'string' || !obj[keys[i]]) return false;
}
return true;
}
/** 音频清单校验:非空对象,且每个值都是非空字符串(相对路径)。 */
function audioIndexValid(obj) {
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return false;
var keys = Object.keys(obj);
if (!keys.length) return false;
for (var i = 0; i < keys.length; i++) {
if (typeof obj[keys[i]] !== 'string' || !obj[keys[i]]) return false;
}
return true;
}
/** 用内容包替换内容字典(数学/逻辑/找规律/古诗题/字母/古诗/SVG/音频)。
逐科目守卫:任一科目残缺或为空 → 跳过该科目、保留内置。
原则:宁可内容包不生效,也绝不能把内置内容原地清空。 */
function applyPack(pack) {
pack = pack || {};
var skipped = [];
if (pack.questions && Array.isArray(pack.questions.items)) {
var b = rebuildBanks(pack.questions.items);
var replaced = false;
// 数学:L1/L2/L3 至少一个非空(L4 是现算的,不要求)
var mathOk = ['L1', 'L2', 'L3'].some(function (lv) {
return Array.isArray(b.math[lv]) && b.math[lv].length > 0;
});
if (mathOk) {
replaceInPlace(MATH_BANK, b.math);
buildMathFlat(); // 题源必须随包重建,否则数学模块仍出内置题(QA 阻断项 1)
replaced = true;
} else skipped.push('数学');
if (b.logic.length > 0) { replaceInPlace(LOGIC_QUIZ, b.logic); replaced = true; }
else skipped.push('逻辑');
if (b.pattern.length > 0) { replaceInPlace(PATTERN_SETS, b.pattern); replaced = true; }
else skipped.push('找规律');
if (b.poemQuiz.length > 0) { replaceInPlace(POEM_QUIZ, b.poemQuiz); replaced = true; }
else skipped.push('古诗题');
if (b.alphabet.length > 0) { replaceInPlace(ALPHABET_SOUNDS, b.alphabet); replaced = true; }
else skipped.push('字母音素');
/* 汉字花园(分级识字):任一分组非空即原地替换,并立刻重画卡片墙。
HANZI_BANK 由 tools/build-hanzi.py 注入,保持引用不变 —— 与数学/字母同一守卫原则。 */
if (b.hanzi) {
['char', 'w2', 'w3', 'w4', 'saying'].forEach(function (g) {
if (b.hanzi[g] && b.hanzi[g].length
&& typeof HANZI_BANK !== 'undefined' && HANZI_BANK[g]) {
replaceInPlace(HANZI_BANK[g], b.hanzi[g]);
replaced = true;
}
});
if (typeof renderHanziAll === 'function') renderHanziAll(); // 内容换了必须重画
}
if (!replaced) skipped.length = 0; // 一项都没换 → 视为整包无效,不逐个报
}
if (pack.poems && Array.isArray(pack.poems.poems)) {
if (poemsValid(pack.poems.poems)) replaceInPlace(poems, pack.poems.poems);
else skipped.push('古诗');
}
if (pack.svg !== undefined && pack.svg !== null) {
if (svgValid(pack.svg)) replaceInPlace(SVG_SHAPES, pack.svg);
else skipped.push('SVG');
}
/* --- 音频分支(方案 C):沿用同一条守卫原则 —— 清单无效/为空则**保留空映射、回退内联种子**,绝不抛。
注意:包内 L 组是满质量、内联种子是降质兜底,二者**故意不同质**。 --- */
if (pack.audio !== undefined && pack.audio !== null) {
if (audioIndexValid(pack.audio)) {
Object.keys(audioMap).forEach(function (kk) { delete audioMap[kk]; });
Object.keys(pack.audio).forEach(function (kk) { audioMap[kk] = BASE + pack.audio[kk]; });
} else skipped.push('音频');
}
if (skipped.length) {
console.warn('[content] 内容包以下科目无效或为空,已保留内置:' + skipped.join('、'));
}
}
/** 异步拉内容包并替换;始终 resolve,失败静默。@returns {Promise} 是否发生了替换 */
function load() {
if (!Platform.content.isAvailable()) return Promise.resolve(false);
return Platform.content.fetchJSON(BASE + 'manifest.json', { noCache: true })
.then(function (man) {
if (!man || man.version === undefined || man.version === null) return false;
if (man.version === appliedVersion || man.version === SEED_VERSION) return false; // 版本相同 → 不替换
return Promise.all([
Platform.content.fetchJSON(BASE + 'questions.json', { version: man.version }),
Platform.content.fetchJSON(BASE + 'poems.json', { version: man.version }),
Platform.content.fetchJSON(BASE + 'assets/svg.json', { version: man.version })
]).then(function (arr) {
if (!arr[0] && !arr[1] && !arr[2]) return false; // 三个都失败 → 静默保持内置
applyPack({ questions: arr[0], poems: arr[1], svg: arr[2] });
appliedVersion = man.version;
refreshAll();
return true;
});
})
.catch(function (e) {
console.warn('[content] 外部内容包加载失败,沿用内置内容:', e && e.message ? e.message : e);
return false;
});
}
/**
* 按需拉音频清单(**不在首屏 3 文件之外扩大首屏拉取面**)。
* TTL 内只拉一次;失败(404 / 离线 / 解析错)一律**静默**返回 false,由 TTS 回退内联种子。
* @returns {Promise} 是否拿到了有效音频映射
*/
function loadAudio() {
if (audioLoaded) return Promise.resolve(Object.keys(audioMap).length > 0);
if (!Platform.content.isAvailable()) return Promise.resolve(false);
return Platform.content.fetchJSON(BASE + 'assets/tts.json')
.then(function (idx) {
audioLoaded = true; // 无论成败都标记,避免每次点击都重试
if (!audioIndexValid(idx)) return false; // 老包没有 tts.json → idx 为 null → 静默回退种子
applyPack({ audio: idx });
return true;
})
.catch(function (e) {
audioLoaded = true;
console.warn('[content] 音频清单加载失败,回退内联种子:', e && e.message ? e.message : e);
return false;
});
}
/** 取某个 key 的包音频 URL;没有则返回 ''(调用方应回退内联种子)。
2026-09-23 起音频文件改**内容寻址**命名(-.m4a):
内容一变,文件名与 URL 就变 → 天然破 immutable 一年强缓存;未变的音频 URL 不变,
老用户缓存继续命中(增量更新不再导致全体重下)。
注意:因此这里**不再追加 ?v=version**:版本号是为 questions/poems 等内容 JSON 服务的
(见上方 load 注释),若音频也带 ?v=,则每次升版本都会把所有音频 URL 全部改掉,
反而让内容寻址带来的增量缓存收益全部失效。 */
function audioUrl(key) {
var u = (key && audioMap[key]) ? audioMap[key] : '';
return u;
}
return {
load: load,
loadAudio: loadAudio,
audioUrl: audioUrl,
hasAudio: function () { return Object.keys(audioMap).length > 0; },
version: function () { return appliedVersion; },
_apply: applyPack
};
})();
/* ------------------------------------------------------------
[S13C] P1-1 使用时长控制(防沉迷)
------------------------------------------------------------
定位:帮家长掌握时长,**不惩罚孩子**。文案一律中性/鼓励 ——
无红色告警、无「玩太久 / 没做完」这类指责。
规则:累计「页面可见」时长 15 分钟 → 顶部温柔提醒条;
20 分钟 → 底部休息画面(**非阻断**,容器 pointer-events:none,不遮挡当前题);
家长答对算术题(复用 C12 requireParentAuth)→ 本轮上限整体 +15 分钟。
实现约束:
· 计时独立于答题结算热路径(独立 interval),不进 QuizEngine;
· 页面切后台不计时(走 Platform.runtime.isVisible / onVisibility);
· 状态存新键 `usage`(走 getData/setData → Platform.storage),不碰任何旧键。
------------------------------------------------------------ */
var USAGE_SOFT_MS = 15 * 60 * 1000; // 第一次温柔提醒
var USAGE_REST_MS = 20 * 60 * 1000; // 休息画面
var USAGE_EXTEND_MS = 15 * 60 * 1000; // 家长续时:上限整体上移
var USAGE_TICK_MS = 30 * 1000; // 累计粒度
var USAGE_SNOOZE_MS = 5 * 60 * 1000; // 「休息好啦」后 5 分钟内不再弹休息卡
var _usage = null; // { date, ms, lastTick, steps, softMark, restMark, snoozeUntil }
var _usageTimer = null;
/** 一天的用量记录(按本地日期归零)。 */
function usageDefault() {
return { date: '', ms: 0, lastTick: 0, steps: 0, softMark: -1, restMark: -1, snoozeUntil: 0 };
}
/** 读取今日累计;跨天则归零。任何异常都回落到安全默认值。 */
function usageLoad() {
var raw = getData('usage', null);
var today = todayISO();
_usage = usageDefault();
_usage.date = today;
_usage.lastTick = Platform.runtime.now();
if (raw && typeof raw === 'object' && raw.date === today) {
if (typeof raw.ms === 'number' && raw.ms >= 0) _usage.ms = raw.ms;
if (typeof raw.steps === 'number' && raw.steps >= 0) _usage.steps = raw.steps;
if (typeof raw.softMark === 'number') _usage.softMark = raw.softMark;
if (typeof raw.restMark === 'number') _usage.restMark = raw.restMark;
if (typeof raw.snoozeUntil === 'number' && raw.snoozeUntil >= 0) _usage.snoozeUntil = raw.snoozeUntil;
}
usageSave();
}
/** 落盘(只写需要的字段;走 Platform 适配层)。 */
function usageSave() {
if (!_usage) return;
setData('usage', {
date: _usage.date,
ms: Math.round(_usage.ms),
steps: _usage.steps,
softMark: _usage.softMark,
restMark: _usage.restMark,
snoozeUntil: _usage.snoozeUntil
});
}
function usageSoftLimit() { return USAGE_SOFT_MS + _usage.steps * USAGE_EXTEND_MS; }
function usageRestLimit() { return USAGE_REST_MS + _usage.steps * USAGE_EXTEND_MS; }
/** 累计一次「可见时长」并判断提醒。仅由定时器 / 切后台瞬间调用。 */
function usageTick() {
if (!_usage) return;
var now = Platform.runtime.now();
var dt = now - (_usage.lastTick || now);
_usage.lastTick = now;
if (dt <= 0) return;
// 长时间挂起(锁屏 / 切走很久)只按一个刻度计入,避免醒来后一次性跳满
if (dt > 5 * 60 * 1000) dt = USAGE_TICK_MS;
_usage.ms += dt;
usageSave();
usageEvaluate();
}
/** 阈值判断。`steps` 变化(家长续时)会让阈值整体上移,因此不会重复提醒。 */
function usageEvaluate() {
if (!_usage) return;
if (_usage.ms >= usageSoftLimit() && _usage.softMark !== _usage.steps) {
_usage.softMark = _usage.steps;
usageSave();
showUsageSoft();
}
if (_usage.ms >= usageRestLimit() &&
_usage.restMark !== _usage.steps &&
Platform.runtime.now() >= (_usage.snoozeUntil || 0)) {
_usage.restMark = _usage.steps;
usageSave();
showRestScene();
}
}
/** 顶部提醒条(不到休息程度时)。 */
function showUsageSoft() {
var el = document.getElementById('usageBanner');
if (!el) return;
var ico = document.getElementById('usageIco');
if (ico && SVG_SHAPES['eye']) ico.innerHTML = svgIcon('eye', 24);
var mins = Math.round(usageSoftLimit() / 60000); // 续时后阈值会变大,文案跟着走
var txt = document.getElementById('usageTxt');
if (txt) txt.textContent = '已经认真学了 ' + mins + ' 分钟,眨眨眼睛、看看远处吧。';
var ext = document.getElementById('usageExtendBtn');
if (ext) ext.hidden = false;
el.hidden = false;
showToast('学得真棒,休息一下眼睛吧', 2600, 'cheer');
}
/** 隐藏提醒条。 */
function hideUsageSoft() {
var el = document.getElementById('usageBanner');
if (el) el.hidden = true;
}
/** 底部休息画面(非阻断)。 */
function showRestScene() {
var el = document.getElementById('restScene');
if (el) el.hidden = false;
}
function hideRestScene(snoozeMs) {
var el = document.getElementById('restScene');
if (el) el.hidden = true;
if (snoozeMs && _usage) {
_usage.snoozeUntil = Platform.runtime.now() + snoozeMs;
usageSave();
}
}
/** 家长续时:复用家长锁(C12)—— 不要新造一套闸门。 */
function extendUsage() {
requireParentAuth(function () {
if (!_usage) return;
_usage.steps += 1; // 上限整体上移一档
_usage.snoozeUntil = 0;
usageSave();
hideRestScene();
hideUsageSoft();
showToast('已续时,接着玩吧', 2200, 'party');
});
}
/** 绑定 P1-1 的按钮(幂等,只绑一次)。 */
function wireUsageUI() {
var close = document.getElementById('usageCloseBtn');
if (close && !close._wired) {
close._wired = true;
close.addEventListener('click', hideUsageSoft);
}
var ext = document.getElementById('usageExtendBtn');
if (ext && !ext._wired) { ext._wired = true; ext.addEventListener('click', extendUsage); }
var done = document.getElementById('restDoneBtn');
if (done && !done._wired) {
done._wired = true;
done.addEventListener('click', function () {
hideRestScene(USAGE_SNOOZE_MS);
showToast('休息好啦,慢慢来', 1800, 'cheer');
});
}
var rext = document.getElementById('restExtendBtn');
if (rext && !rext._wired) { rext._wired = true; rext.addEventListener('click', extendUsage); }
}
/** 启动计时。快照(只读)版不计时。 */
function startUsageTimer() {
if (SNAPSHOT_MODE) return;
usageLoad();
wireUsageUI();
Platform.runtime.onVisibility(function () {
if (Platform.runtime.isVisible()) {
if (_usage) _usage.lastTick = Platform.runtime.now(); // 回到前台:从此刻重新计时,后台那段不补
} else {
usageTick(); // 切走前把这一段计入并落盘
}
});
if (_usageTimer !== null) Platform.runtime.clear(_usageTimer);
_usageTimer = Platform.runtime.interval(function () {
if (!Platform.runtime.isVisible()) return; // 后台不计时
usageTick();
}, USAGE_TICK_MS);
}
/* ===== [S-A11Y3] 显示设置:夜间档 + 字号三档(P1-2)=====
状态走 getData/setData(底层 Platform.storage),系统偏好走 Platform.media。
优先级:手动设置过 > 系统 prefers-color-scheme > 浅色。 */
var NIGHT_QUERY = '(prefers-color-scheme: dark)';
var FONT_TIERS = ['s', 'm', 'l'];
var FONT_LABEL = { s: '小', m: '中', l: '大' };
/** @returns {'s'|'m'|'l'} 当前字号档;读到非法值一律回落到「中」。 */
function getFontTier() {
var f = getData('ui_font', 'm');
return FONT_TIERS.indexOf(f) >= 0 ? f : 'm';
}
/** @returns {boolean} 当前是否夜间档。手动设置过就用手动值,否则跟随系统。 */
function isNight() {
var v = getData('ui_night', null);
if (v === 'night' || v === 'day') return v === 'night';
return Platform.media.matches(NIGHT_QUERY);
}
/** 把当前设置写到 的属性上,并同步家长区两个按钮的文案。 */
function applyUi() {
var r = document.documentElement;
if (!r) return;
var night = isNight(), font = getFontTier();
r.setAttribute('data-theme', night ? 'night' : 'day');
r.setAttribute('data-font', font);
var tb = document.getElementById('themeBtn');
var fb = document.getElementById('fontBtn');
if (tb) tb.textContent = night ? '夜间:开' : '夜间:关';
if (fb) fb.textContent = '字号:' + FONT_LABEL[font];
}
function toggleNight() {
setData('ui_night', isNight() ? 'day' : 'night');
applyUi();
}
function cycleFont() {
var f = getFontTier();
setData('ui_font', FONT_TIERS[(FONT_TIERS.indexOf(f) + 1) % FONT_TIERS.length]);
applyUi();
}
// 没手动设置过时,系统深浅切换要能跟得上;手动设置过就不再被系统覆盖。
Platform.media.onChange(NIGHT_QUERY, function () {
var v = getData('ui_night', null);
if (v !== 'night' && v !== 'day') applyUi();
});
/* ===== [S13] 初始化 ===== */
(function init() {
// P1-2:显示设置必须最先落地(只改 属性),否则夜间档下会先闪一下白底。
applyUi();
updateStarDisplay();
renderBadges();
renderTodayPanel();
renderGradePicker();
renderGradeBadge();
updateSyncState();
renderClearedInfo();
wireLockButtons();
// T06/C22/C24:语音能力判定(L0/L1)+ 刷新 L0 提示位 + 首次手势预热。
// 必须放在下面任何早退(快照 / 同意弹窗)之前,保证各入口的朗读行为一致。
armSpeechWarmup();
refreshSpeechNotices();
// T-PACK-UI([PLATFORM:content]):内置内容已渲染完毕,异步替换外部内容包。
// 不 await、不阻塞首屏;拉包失败静默(无 content/ 时行为与内置完全一致)。
ContentPack.load();
// T-C4(R5):首屏**之后**的空闲时段才预取音频清单,不与首屏抢带宽。
// 失败静默 —— 拿不到包音频就继续用内联种子,绝不提示、绝不阻塞。
if (TTS && typeof TTS.prefetchAudio === 'function') TTS.prefetchAudio();
// P1-1 使用时长控制:启动「可见时长」计时 + 绑定提醒/休息/续时按钮。
// 与语音一样放在任何早退(快照 / 同意弹窗)之前,保证各入口行为一致(快照版内部自跳过)。
startUsageTimer();
// 宝贝昵称(C13):真名只在本机。修改昵称需家长锁(C12 第 5 个锁点)。
var nameEl = document.getElementById('babyName');
if (nameEl) {
restoreNameInput();
renderWelcome();
nameEl.addEventListener('input', cacheNameInput); // 边打边缓存(本机即时)
nameEl.addEventListener('change', function () {
var val = nameEl.value;
var saved = getData('name_input_cache', '');
if (val === saved) { showToast('名字已保存', 1500, 'pencil'); return; }
// 先回到已保存值,家长锁通过后再落盘 —— 挡住孩子随手改名
nameEl.value = saved;
requireParentAuth(function () {
nameEl.value = val;
cacheNameInput();
renderWelcome();
showToast('名字已保存', 1500, 'pencil');
});
});
}
// URL 参数支持(收敛:走 Platform.env,业务层不直接碰 window.location)
const params = Platform.env.query();
const lang = params.get('lang') || 'zh';
if (lang !== 'zh') applyI18N(lang);
const urlPage = params.get('page');
if (urlPage) switchPage(urlPage);
// C16b:无 SDK + 有内联快照 → 纯只读快照模式,零写入
var snap = readSnapshot();
if (!CLOUD_ON && snap) { enterSnapshotMode(snap); return; }
// C14:首次打开先弹监护人同意;决定之前不读也不写云端
wireConsentButtons();
if (getConsent() === '') { showConsentModal(); return; }
bootSync();
})();
/** 启动云端同步(探测 + 拉全量 + 订阅变更)。 */
function bootSync() {
loadCloudData();
subscribeUpdates();
}
/* ---- C14:监护人同意弹窗 ---- */
function showConsentModal() {
var mask = document.getElementById('consentMask');
if (mask) mask.hidden = false;
}
function hideConsentModal() {
var mask = document.getElementById('consentMask');
if (mask) mask.hidden = true;
}
/** 绑定同意弹窗按钮(只绑一次)。 */
function wireConsentButtons() {
var agree = document.getElementById('consentAgree');
var deny = document.getElementById('consentDeny');
if (agree && !agree._wired) {
agree._wired = true;
agree.addEventListener('click', function () {
setConsent('granted');
hideConsentModal();
renderClearedInfo();
renderConsentBlock();
activateCloudThen(bootSync); // 同意后才允许读 / 写云端(T05:首次先注册换 token)
});
}
if (deny && !deny._wired) {
deny._wired = true;
deny.addEventListener('click', function () {
setConsent('denied');
hideConsentModal();
renderClearedInfo();
renderConsentBlock();
refreshViews(); // 仅本机使用:不读也不写云端,纯本地
});
}
}
/* ------------------------------------------------------------
C14 / T05.1:同步设置块 —— 只要「同步尚未真正激活」就常驻入口。
两种情况都必须给出口(否则同步会永远点不到):
· denied(家长选了「仅本机使用」)→ 点一下改回 granted 并把增量补传上去;
· granted 但 !CLOUD_ON(同意过却没连上云端:当时无后端 / 注册失败)
→ 点一下补注册换 token,把云同步真正打开。
只有「已同意且云端已激活」才隐藏,避免干扰。
------------------------------------------------------------ */
function renderConsentBlock() {
var box = document.getElementById('pzConsentBlock');
if (!box) return;
var hint = document.getElementById('pzConsentHint');
var btn = document.getElementById('pzAllowSyncBtn');
// 快照(只读)版:不涉及同步设置,直接隐藏
if (SNAPSHOT_MODE) { box.hidden = true; if (btn) btn.hidden = true; return; }
var consent = getConsent();
// T05.1:判据由「仅 denied」放宽为「同步尚未真正激活」(consent !== 'granted' || !CLOUD_ON)。
if (consent === 'granted' && CLOUD_ON) { // 已同意且云端已激活 → 无需入口
box.hidden = true;
if (btn) btn.hidden = true; // 归一按钮状态,避免父块隐藏时按钮仍带 visible
return;
}
box.hidden = false;
var n = pendingCount();
if (hint) {
if (consent === 'denied') {
// 语义与今天完全一致:家长明确选了「仅本机使用」。
hint.innerHTML = '当前选择「仅本机使用」,进度只存在这台设备上,不会上传云端。' +
(n > 0 ? '本机已攒下 ' + n + ' 条待同步记录。' : '') +
'想换设备接着玩,点下面按钮即可重新开启同步。';
} else {
// granted 但云端未激活:多半是当时没连上,给一次「开启云同步」的机会(不提「仅本机使用」)。
hint.innerHTML = '云同步还没开启(可能是当时没连上)。进度现在只存在这台设备上,' +
'点下面按钮开启云同步,' +
(n > 0 ? '本机已攒下的 ' + n + ' 条记录会一起补传上去。' : '之后换设备也能接着玩。');
}
}
if (btn) {
// T05.1:按钮的职责就是「触发注册」,注册成功后 CLOUD_ON 自会变 true,
// 故不能再拿 CLOUD_ON 隐藏它(那正是「永远点不到」的根因)。
btn.hidden = false;
btn.textContent = consent === 'denied'
? (n > 0 ? '允许同步到云端(补传 ' + n + ' 条)' : '允许同步到云端')
: '开启云同步';
}
}
/* ------------------------------------------------------------
P1:换设备(短配对码)—— 只在家长区(家长锁之后)出现。
硬约束:
· 倒计时走 Platform.runtime.interval(业务层禁直调 setInterval / clearInterval);
· 不引入 navigator.clipboard(码用「可选中大号文本 + 手抄提示」);
· 快照(SNAPSHOT_MODE)整块隐藏,且两个动作都早退 → 不发任何请求;
· 重新生成码时提示「上一个码已失效」(每 profile 只留 1 张码,§0.2-⑤)。
------------------------------------------------------------ */
var _pairTimer = null; // Platform.runtime.interval 句柄
var _pairExpiresAt = 0; // 当前码的过期时刻(本机时钟)
/** 停掉倒计时(幂等)。 */
function stopPairTimer() {
if (_pairTimer !== null) { Platform.runtime.clear(_pairTimer); _pairTimer = null; }
}
/** 「m 分 ss 秒后失效」文案(无 emoji、温和)。 */
function pairCountdownText(remainSec) {
var m = Math.floor(remainSec / 60);
var s = remainSec % 60;
return '这个码 ' + m + ' 分 ' + (s < 10 ? '0' + s : String(s)) + ' 秒后失效。';
}
/** 启动倒计时(每秒刷新;到点自停并提示失效)。 */
function startPairCountdown(ttlSeconds) {
stopPairTimer();
var cdEl = document.getElementById('pzPairCountdown');
var ttl = Number(ttlSeconds);
if (!isFinite(ttl) || ttl <= 0) ttl = 0;
_pairExpiresAt = Platform.runtime.now() + ttl * 1000;
function tick() {
var remain = Math.round((_pairExpiresAt - Platform.runtime.now()) / 1000);
if (remain < 0) remain = 0;
if (cdEl) cdEl.textContent = remain > 0 ? pairCountdownText(remain) : '这个码已失效,请重新生成。';
if (remain <= 0) stopPairTimer();
}
tick();
if (cdEl) cdEl.hidden = false;
_pairTimer = Platform.runtime.interval(tick, 1000); // 平台时序能力,不直调 setInterval
}
/** 失败提示:按 err.status 区分「限流(429) / 无效码(404) / 格式(400)」,不泄露内部细节。 */
function pairFailMessage(err) {
var status = err && err.status;
if (status === 429) return '操作有点频繁,请等一小会儿再试一次';
if (status === 404) return '迁移码无效或已过期,请在原来那台设备上重新生成';
if (status === 400) return '迁移码格式不太对,请照着原设备再输一次';
return '暂时连不上云端,请稍后再试';
}
/** P1:显示 / 隐藏「换设备」块(由 updateSyncState() 联动调用)。 */
function renderPairingBlock() {
var box = document.getElementById('pzPairBlock');
if (!box) return;
// 快照(只读)版:不得签发 / 兑换凭证 → 整块隐藏(两个动作也会早退,不发请求)
if (SNAPSHOT_MODE) { box.hidden = true; stopPairTimer(); return; }
box.hidden = false;
var btn = document.getElementById('pzPairMintBtn');
if (btn) btn.disabled = !CLOUD_ON; // 云端未激活时先禁用(「同步设置」块里有开启入口)
}
/** P1:点击「生成迁移码」。仅在被点击时才发请求。 */
function mintPairingCode() {
if (SNAPSHOT_MODE) { showToast('快照页不写数据'); return; }
if (!CLOUD_ON) { showToast('请先在「同步设置」里开启云同步', 2400, 'think'); return; }
var btn = document.getElementById('pzPairMintBtn');
var codeEl = document.getElementById('pzPairCode');
var noticeEl = document.getElementById('pzPairNotice');
var isRegen = !!(codeEl && !codeEl.hidden && codeEl.textContent); // 已有码 → 本次是重新生成
if (btn) btn.disabled = true;
showToast('正在生成迁移码…', 1400, 'think');
Platform.cloud.createPairing().then(function (data) {
var code = (data && data.code) ? String(data.code) : '';
if (codeEl) { codeEl.textContent = code; codeEl.hidden = !code; }
if (noticeEl) {
// §0.2-⑤:每 profile 只留 1 张码 → 重新生成时明确告知旧码已失效
noticeEl.hidden = false;
noticeEl.innerHTML = isRegen
? '上一个码已失效,请用下面这个新码。'
: '请把下面的码在有效期内输入到新设备。';
}
startPairCountdown(data && data.ttlSeconds);
showToast('迁移码已生成', 1800, 'star');
}, function (err) {
showToast(pairFailMessage(err), 2800, 'think');
}).then(function () { if (btn) btn.disabled = !CLOUD_ON; });
}
/** P1:点击「使用迁移码」(在新设备上)。成功后开启同步并拉回进度。 */
function redeemPairingCode() {
if (SNAPSHOT_MODE) { showToast('快照页不写数据'); return; }
var input = document.getElementById('pzPairInput');
var code = input ? String(input.value || '') : '';
if (!code.replace(/[\s-]/g, '')) { showToast('请先输入迁移码', 1800, 'think'); return; }
showToast('正在恢复进度…', 1600, 'think');
Platform.cloud.redeemPairing(code).then(function () {
// 家长动作,等价开启同步(§0.1-2):redeem 内部已把新 token 落盘
setConsent('granted');
refreshCloud(); // 就地重赋 db / CLOUD_ON → 云同步立即生效
bootSync();
updateSyncState();
if (input) input.value = '';
showToast('已恢复进度,云端同步已在这台设备开启', 2600, 'star');
}, function (err) {
showToast(pairFailMessage(err), 2800, 'think');
});
}
/**
* T05:家长同意开启同步时的前置步骤。
* 若本机还没有云端凭证(且平台未注入 SDK),先注册一台匿名设备换 token,
* 再把云层激活(refreshCloud)并继续引导;已有凭证 / 有 SDK 则直接继续。
* 注册失败一律降级为「纯本机」:只 toast 提示,不抛硬错、不改其它任何行为。
* @param {Function} next 就绪后要执行的动作;入参 cloudReady 表示云层是否已就绪
*/
function activateCloudThen(next) {
if (typeof next !== 'function') next = function () {};
if (Platform.cloud.hasSdk() || Platform.cloud.hasToken()) { refreshCloud(); next(CLOUD_ON); return; }
Platform.cloud.register().then(function () {
refreshCloud();
next(CLOUD_ON); // 成功:token 已落盘 → CLOUD_ON 必为 true
}, function () {
refreshCloud(); // 仍可能为 null → 纯本机
showToast('云端暂时连不上,先只存在本机,不影响使用', 2600, 'think');
next(false);
});
}
/** C14:把「仅本机使用」改回「允许同步」,并把攒下的增量补传上去。 */
function setConsentGranted() {
if (SNAPSHOT_MODE) { showToast('快照页不写数据'); return; }
var n = pendingCount();
setConsent('granted');
renderConsentBlock();
renderClearedInfo();
activateCloudThen(function (cloudReady) { // T05:首次开启时先注册换 token,再激活云层
if (CLOUD_ON) bootSync(); // 重新允许后才读 / 写云端
updateSyncState();
refreshViews();
// 注册失败时不覆盖 activateCloudThen 已弹出的「连不上」提示,避免谎报「已同步」
if (cloudReady) showToast(n > 0 ? '已允许同步,正在补传 ' + n + ' 条…' : '已允许同步到云端', 2200, 'party');
});
}
/* ---- C12:家长锁弹窗按钮 ---- */
function wireLockButtons() {
var cancel = document.getElementById('lockCancel');
if (cancel && !cancel._wired) {
cancel._wired = true;
cancel.addEventListener('click', cancelLock);
}
var mask = document.getElementById('lockMask');
if (mask && !mask._wired) {
mask._wired = true;
// 点遮罩(非弹窗本体)也算取消
mask.addEventListener('click', function (e) { if (e.target === mask) cancelLock(); });
}
}