const DAILY_GOAL = 5,
ROUND_SIZE = 5;
// === AUDIO with mobile unlock ===
var AudioCtx = window.AudioContext || window.webkitAudioContext;
var audioCtx = null,
audioUnlocked = false;
function unlockAudio() {
if (audioUnlocked || !audioCtx) return;
var buf = audioCtx.createBuffer(1, 1, 22050);
var src = audioCtx.createBufferSource();
src.buffer = buf;
src.connect(audioCtx.destination);
src.start(0);
if (audioCtx.state === "suspended") audioCtx.resume();
audioUnlocked = true;
}
function ensureAudio() {
if (!audioCtx) audioCtx = new AudioCtx();
unlockAudio();
}
document.addEventListener(
"touchstart",
function () {
ensureAudio();
},
{ once: true },
);
document.addEventListener(
"click",
function () {
ensureAudio();
},
{ once: true },
);
function playTone(f, d, t, v) {
if (!state.sound) return;
t = t || "sine";
v = v || 0.3;
try {
ensureAudio();
var o = audioCtx.createOscillator(),
g = audioCtx.createGain();
o.type = t;
o.frequency.value = f;
g.gain.setValueAtTime(v, audioCtx.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + d);
o.connect(g);
g.connect(audioCtx.destination);
o.start();
o.stop(audioCtx.currentTime + d);
} catch (e) {}
}
function sfxCorrect() {
playTone(523, 0.12);
setTimeout(function () {
playTone(659, 0.12);
}, 80);
setTimeout(function () {
playTone(784, 0.2);
}, 160);
}
function sfxWrong() {
playTone(200, 0.15, "square", 0.2);
setTimeout(function () {
playTone(160, 0.2, "square", 0.2);
}, 120);
}
function sfxClick() {
playTone(440, 0.06, "sine", 0.15);
}
function sfxCombo() {
playTone(523, 0.1);
setTimeout(function () {
playTone(659, 0.1);
}, 60);
setTimeout(function () {
playTone(784, 0.1);
}, 120);
setTimeout(function () {
playTone(1047, 0.25);
}, 180);
}
function sfxComplete() {
[523, 587, 659, 784, 1047].forEach(function (n, i) {
setTimeout(function () {
playTone(n, 0.2, "sine", 0.25);
}, i * 100);
});
}
// === STATE ===
const APP_VERSION = 2;
var state = {
sound: true,
bigFont: false,
difficulty: 1,
score: 0,
combo: 0,
maxCombo: 0,
todayCount: 0,
totalCount: 0,
streak: 0,
checkinDays: [],
round: 0,
roundResults: [],
currentIdiom: null,
blankIndices: [],
selectedBlank: -1,
filledChars: {},
hintUsed: false,
answered: false,
usedIdiomIndices: {},
wrongBook: [],
reviewMode: false,
};
var currentOptions = [];
function getToday() {
return new Date().toISOString().slice(0, 10);
}
function saveState() {
localStorage.setItem(
"chengyu_s",
JSON.stringify({
sound: state.sound,
bigFont: state.bigFont,
difficulty: state.difficulty,
totalCount: state.totalCount,
streak: state.streak,
checkinDays: state.checkinDays,
maxCombo: state.maxCombo,
}),
);
}
function saveWrongBook() {
localStorage.setItem("chengyu_wb", JSON.stringify(state.wrongBook));
}
function loadWrongBook() {
try {
state.wrongBook = JSON.parse(localStorage.getItem("chengyu_wb") || "[]");
} catch (e) {
state.wrongBook = [];
}
}
function addToWrongBook(idiom) {
var ex = null;
for (var i = 0; i < state.wrongBook.length; i++) {
if (state.wrongBook[i].w === idiom.w) {
ex = state.wrongBook[i];
break;
}
}
if (ex) {
ex.count++;
ex.last = getToday();
} else {
state.wrongBook.push({
w: idiom.w,
p: idiom.p,
e: idiom.e,
l: idiom.l,
count: 1,
last: getToday(),
});
}
saveWrongBook();
renderWbBadge();
}
function removeFromWrongBook(w) {
state.wrongBook = state.wrongBook.filter(function (x) {
return x.w !== w;
});
saveWrongBook();
renderWbBadge();
}
function renderWbBadge() {
var b = document.getElementById("wbBadge");
if (!b) return;
b.textContent = state.wrongBook.length;
b.className = "wb-badge" + (state.wrongBook.length === 0 ? " empty" : "");
}
function showWrongBookModal() {
document.getElementById("wrongBookModal").classList.add("show");
document.getElementById("wbCount").textContent = state.wrongBook.length;
var list = document.getElementById("wbList");
var startBtn = document.getElementById("btnStartReview");
if (!state.wrongBook.length) {
list.innerHTML = '
🎉 暂无错题,梅子很棒!
';
startBtn.style.display = "none";
} else {
startBtn.style.display = "";
list.innerHTML = "";
var sorted = state.wrongBook.slice().sort(function (a, b) {
return b.count - a.count;
});
sorted.forEach(function (item) {
var d = document.createElement("div");
d.className = "wb-item";
d.innerHTML =
'' +
item.w +
'
' +
item.p +
'
' +
item.e +
'
错误 ' +
item.count +
" 次
";
list.appendChild(d);
});
}
}
function startReview() {
document.getElementById("wrongBookModal").classList.remove("show");
state.reviewMode = true;
state.usedIdiomIndices = {};
startRound();
}
function checkWhatsNew() {
var v = parseInt(localStorage.getItem("chengyu_v") || "0");
if (v < APP_VERSION) {
document.getElementById("whatsNewModal").classList.add("show");
}
}
function saveTodayState() {
localStorage.setItem(
"chengyu_t",
JSON.stringify({
d: getToday(),
s: state.score,
c: state.todayCount,
cb: state.combo,
}),
);
}
function loadState() {
try {
var s = JSON.parse(localStorage.getItem("chengyu_s") || "{}");
state.sound = s.sound !== undefined ? s.sound : true;
state.bigFont = s.bigFont || false;
state.totalCount = s.totalCount || 0;
state.streak = s.streak || 0;
state.checkinDays = s.checkinDays || [];
state.maxCombo = s.maxCombo || 0;
state.difficulty = s.difficulty || 1;
var t = JSON.parse(localStorage.getItem("chengyu_t") || "{}");
if (t.d === getToday()) {
state.score = t.s || 0;
state.todayCount = t.c || 0;
state.combo = t.cb || 0;
}
updateStreak();
} catch (e) {}
}
function updateStreak() {
var today = getToday(),
days = state.checkinDays;
if (!days.length) {
state.streak = 0;
return;
}
var sorted = days.slice().sort().reverse();
var streak = 0,
cd = new Date(today + "T00:00:00");
if (sorted[0] === today) {
for (var i = 0; i < sorted.length; i++) {
var e = new Date(cd);
e.setDate(e.getDate() - i);
if (sorted[i] === e.toISOString().slice(0, 10)) streak++;
else break;
}
} else {
var y = new Date(cd);
y.setDate(y.getDate() - 1);
var ys = y.toISOString().slice(0, 10);
if (sorted[0] === ys) {
for (var i = 0; i < sorted.length; i++) {
var e = new Date(y);
e.setDate(e.getDate() - i);
if (sorted[i] === e.toISOString().slice(0, 10)) streak++;
else break;
}
}
}
state.streak = streak;
}
function tryCheckin() {
var t = getToday();
if (state.todayCount >= DAILY_GOAL && state.checkinDays.indexOf(t) === -1) {
state.checkinDays.push(t);
if (state.checkinDays.length > 60)
state.checkinDays = state.checkinDays.slice(-60);
updateStreak();
saveState();
return true;
}
return false;
}
// === GAME LOGIC ===
function pickIdiom() {
if (state.reviewMode && state.wrongBook.length) {
var pool = state.wrongBook.filter(function (x) {
return !state.usedIdiomIndices[x.w];
});
if (!pool.length) {
state.usedIdiomIndices = {};
pool = state.wrongBook.slice();
}
var item = pool[Math.floor(Math.random() * pool.length)];
state.usedIdiomIndices[item.w] = 1;
return item;
}
var pool = IDIOMS.filter(function (i) {
return i.l <= state.difficulty;
});
var a = 0,
idx;
do {
idx = Math.floor(Math.random() * pool.length);
a++;
} while (state.usedIdiomIndices[pool[idx].w] && a < 100);
state.usedIdiomIndices[pool[idx].w] = 1;
return pool[idx];
}
function generateBlanks(w, d) {
var n = d === 1 ? 1 : d === 2 ? 2 : 3;
var idx = [0, 1, 2, 3];
for (var i = 3; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var tmp = idx[i];
idx[i] = idx[j];
idx[j] = tmp;
}
return idx.slice(0, n).sort();
}
function generateOptions(idiom, blanks) {
var correct = blanks.map(function (i) {
return idiom.w[i];
});
var decoyN = blanks.length <= 1 ? 3 : blanks.length === 2 ? 4 : 5;
var allC = {};
IDIOMS.forEach(function (it) {
for (var i = 0; i < it.w.length; i++) allC[it.w[i]] = 1;
});
var pool = Object.keys(allC).filter(function (c) {
return correct.indexOf(c) === -1;
});
var decoys = [],
used = {};
correct.forEach(function (c) {
used[c] = 1;
});
while (decoys.length < decoyN && pool.length) {
var i = Math.floor(Math.random() * pool.length);
if (!used[pool[i]]) {
decoys.push(pool[i]);
used[pool[i]] = 1;
}
pool.splice(i, 1);
}
var opts = correct.concat(decoys);
for (var i = opts.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var tmp = opts[i];
opts[i] = opts[j];
opts[j] = tmp;
}
return opts;
}
// === RENDER ===
function renderStats() {
document.getElementById("statScore").textContent = state.score;
document.getElementById("statCombo").textContent = state.combo;
document.getElementById("statTotal").textContent = state.totalCount;
document.getElementById("statStreak").textContent = state.streak;
}
function renderProgress() {
var bar = document.getElementById("progressBar");
bar.innerHTML = "";
for (var i = 0; i < ROUND_SIZE; i++) {
var d = document.createElement("div");
d.className =
"progress-dot" +
(i < state.round
? state.roundResults[i]
? " done"
: " skip"
: i === state.round
? " current"
: "");
bar.appendChild(d);
}
}
function renderIdiom() {
var el = document.getElementById("idiomDisplay");
el.innerHTML = "";
var idiom = state.currentIdiom,
pp = idiom.p.split(" ");
for (var i = 0; i < idiom.w.length; i++) {
var box = document.createElement("div");
box.className = "char-box";
if (state.blankIndices.indexOf(i) !== -1) {
box.classList.add("blank");
if (state.filledChars[i]) {
box.classList.remove("blank");
box.classList.add("filled");
box.textContent = state.filledChars[i];
} else {
box.textContent = "?";
if (state.selectedBlank === i) box.classList.add("selected");
}
(function (idx) {
box.addEventListener("click", function () {
selectBlank(idx);
});
})(i);
} else {
box.textContent = idiom.w[i];
var py = document.createElement("span");
py.className = "pinyin-hint";
py.textContent = pp[i] || "";
box.appendChild(py);
}
el.appendChild(box);
}
}
function renderOptions(opts) {
var grid = document.getElementById("optionsGrid");
grid.innerHTML = "";
var filled = Object.keys(state.filledChars).map(function (k) {
return state.filledChars[k];
});
opts.forEach(function (ch) {
var btn = document.createElement("button");
btn.className = "option-btn";
btn.textContent = ch;
if (filled.indexOf(ch) !== -1) btn.classList.add("used");
if (state.answered) btn.classList.add("disabled");
btn.addEventListener("click", function () {
selectOption(ch);
});
grid.appendChild(btn);
});
}
function renderCombo() {
var el = document.getElementById("comboDisplay");
var toast = document.getElementById("comboToast");
if (state.combo >= 3) {
var msgs = [
"梅子不错哦~",
"太棒了梅子!",
"梅子真厉害!",
"学富五车!",
"梅子无人能挡!",
"登峰造极!",
];
var i = Math.min(Math.floor((state.combo - 3) / 2), msgs.length - 1);
var txt = "🔥 连击 ×" + state.combo + " " + msgs[i];
el.innerHTML = '' + txt + "";
if (toast) {
toast.textContent = txt;
toast.classList.add("show");
clearTimeout(window._comboTimer);
window._comboTimer = setTimeout(function () {
toast.classList.remove("show");
}, 2200);
}
} else {
el.innerHTML = "";
if (toast) toast.classList.remove("show");
}
}
// === ACTIONS ===
function startRound() {
state.round = 0;
state.roundResults = [];
nextQuestion();
}
function nextQuestion() {
if (state.round >= ROUND_SIZE) {
finishRound();
return;
}
state.currentIdiom = pickIdiom();
state.blankIndices = generateBlanks(state.currentIdiom.w, state.difficulty);
state.selectedBlank = state.blankIndices[0];
state.filledChars = {};
state.hintUsed = false;
state.answered = false;
currentOptions = generateOptions(state.currentIdiom, state.blankIndices);
document.getElementById("btnNext").style.display = "none";
document.getElementById("btnHint").style.display = "";
document.getElementById("btnHint").style.opacity = "1";
document.getElementById("btnHint").style.pointerEvents = "";
document.getElementById("btnSkip").style.display = "";
document.getElementById("resultArea").innerHTML = "";
document.getElementById("optLabel").style.display = "";
renderProgress();
renderIdiom();
renderOptions(currentOptions);
renderCombo();
renderStats();
}
function selectBlank(i) {
if (state.answered) return;
if (state.filledChars[i]) {
delete state.filledChars[i];
state.selectedBlank = i;
sfxClick();
renderIdiom();
renderOptions(currentOptions);
return;
}
state.selectedBlank = i;
sfxClick();
renderIdiom();
}
function selectOption(ch) {
if (state.answered || state.selectedBlank === -1) return;
sfxClick();
state.filledChars[state.selectedBlank] = ch;
var next = -1;
for (var j = 0; j < state.blankIndices.length; j++) {
if (!state.filledChars[state.blankIndices[j]]) {
next = state.blankIndices[j];
break;
}
}
state.selectedBlank = next;
renderIdiom();
renderOptions(currentOptions);
var allFilled = true;
for (var j = 0; j < state.blankIndices.length; j++) {
if (!state.filledChars[state.blankIndices[j]]) {
allFilled = false;
break;
}
}
if (allFilled) checkAnswer();
}
function checkAnswer() {
state.answered = true;
var idiom = state.currentIdiom,
ok = true;
state.blankIndices.forEach(function (i) {
if (state.filledChars[i] !== idiom.w[i]) ok = false;
});
var boxes = document.querySelectorAll(".char-box");
state.blankIndices.forEach(function (i) {
boxes[i].classList.remove("filled");
boxes[i].classList.add(
state.filledChars[i] === idiom.w[i] ? "correct" : "wrong",
);
});
if (ok) {
state.combo++;
if (state.combo > state.maxCombo) state.maxCombo = state.combo;
state.score += Math.round(
state.difficulty *
10 *
(state.hintUsed ? 0.5 : 1) *
(1 + state.combo * 0.1),
);
state.todayCount++;
state.totalCount++;
state.roundResults.push(true);
state.combo >= 5 ? sfxCombo() : sfxCorrect();
if (state.reviewMode) removeFromWrongBook(idiom.w);
var nc = tryCheckin();
showResult(true, nc);
} else {
state.combo = 0;
state.roundResults.push(false);
sfxWrong();
addToWrongBook(idiom);
setTimeout(function () {
state.blankIndices.forEach(function (i) {
boxes[i].textContent = idiom.w[i];
boxes[i].classList.remove("wrong");
boxes[i].classList.add("correct");
});
}, 800);
showResult(false, false);
}
renderOptions(currentOptions);
renderCombo();
renderStats();
renderProgress();
saveTodayState();
saveState();
document.getElementById("btnNext").style.display = "";
document.getElementById("btnHint").style.display = "none";
document.getElementById("btnSkip").style.display = "none";
}
function showResult(ok, nc) {
var idiom = state.currentIdiom,
a = document.getElementById("resultArea");
var ch = "";
if (nc)
ch =
'🎉 梅子今日打卡成功! 连续 ' +
state.streak +
" 天
";
var pg = "";
if (!nc && state.todayCount < DAILY_GOAL)
pg =
'今日进度:' +
state.todayCount +
"/" +
DAILY_GOAL +
" 题(完成" +
DAILY_GOAL +
"题即可打卡)
";
a.innerHTML =
'' +
(ok ? "✅ 回答正确!" : "❌ 答错了~") +
(ok && state.combo >= 3 ? " 🔥" : "") +
'
' +
idiom.w +
'
' +
idiom.p +
'
释义:' +
idiom.e +
"
" +
ch +
pg +
"
";
}
function finishRound() {
var c = state.roundResults.filter(function (r) {
return r;
}).length;
sfxComplete();
var wasReview = state.reviewMode;
state.reviewMode = false;
var msg =
c === ROUND_SIZE
? "满分!梅子太厉害了!🎉"
: c >= 3
? "梅子表现不错!继续加油~"
: "没关系梅子,多练习就会进步!💪";
var reviewBtn =
wasReview && state.wrongBook.length
? ''
: "";
document.getElementById("resultArea").innerHTML =
'' +
(wasReview ? "📖 错题练习结束!" : "🏆 本轮结束!") +
'
' +
c +
" / " +
ROUND_SIZE +
'
' +
msg +
'
' +
reviewBtn +
"
";
document.getElementById("btnNext").style.display = "none";
document.getElementById("btnHint").style.display = "none";
document.getElementById("btnSkip").style.display = "none";
document.getElementById("idiomDisplay").innerHTML = "";
document.getElementById("optionsGrid").innerHTML = "";
document.getElementById("optLabel").style.display = "none";
}
function useHint() {
if (state.answered || state.hintUsed) return;
state.hintUsed = true;
var ub = [];
for (var j = 0; j < state.blankIndices.length; j++) {
if (!state.filledChars[state.blankIndices[j]])
ub.push(state.blankIndices[j]);
}
if (!ub.length) return;
var hi = ub[0],
pp = state.currentIdiom.p.split(" "),
boxes = document.querySelectorAll(".char-box"),
py = document.createElement("span");
py.className = "pinyin-hint";
py.textContent = pp[hi] || "";
py.style.color = "var(--accent)";
boxes[hi].appendChild(py);
sfxClick();
document.getElementById("btnHint").style.opacity = ".5";
document.getElementById("btnHint").style.pointerEvents = "none";
}
function skipQuestion() {
if (state.answered) return;
state.combo = 0;
state.roundResults.push(false);
state.round++;
renderCombo();
renderProgress();
nextQuestion();
}
function showCheckinModal() {
document.getElementById("checkinModal").classList.add("show");
document.getElementById("modalStreak").textContent = state.streak;
var tc = document.getElementById("modalTodayCount");
var gf = document.getElementById("modalGoalFill");
if (tc) tc.textContent = state.todayCount;
if (gf)
gf.style.width =
Math.min(100, Math.round((state.todayCount / DAILY_GOAL) * 100)) + "%";
var cal = document.getElementById("modalCalendar");
cal.innerHTML = "";
var today = new Date(),
ts = getToday();
["一", "二", "三", "四", "五", "六", "日"].forEach(function (l) {
var d = document.createElement("div");
d.style.cssText =
"font-size:.65rem;color:#8B6914;display:flex;align-items:center;justify-content:center";
d.textContent = l;
cal.appendChild(d);
});
var start = new Date(today);
start.setDate(start.getDate() - 27);
while (start.getDay() !== 1) start.setDate(start.getDate() - 1);
var end = new Date(today);
end.setDate(end.getDate() + ((7 - today.getDay()) % 7));
var cur = new Date(start);
while (cur <= end) {
var ds = cur.toISOString().slice(0, 10),
d = document.createElement("div");
d.className = "cal-day";
d.textContent = cur.getDate();
if (state.checkinDays.indexOf(ds) !== -1) d.classList.add("checked");
if (ds === ts) d.classList.add("today");
if (cur > today) d.style.opacity = ".3";
cal.appendChild(d);
cur.setDate(cur.getDate() + 1);
}
}
// === INIT ===
function init() {
loadState();
loadWrongBook();
renderWbBadge();
if (state.bigFont) document.body.classList.add("big-font");
document.getElementById("btnSound").classList.toggle("active", state.sound);
document.getElementById("btnSound").textContent = state.sound ? "🔊" : "🔇";
if (state.bigFont)
document.getElementById("btnBigFont").classList.add("active");
document.querySelectorAll(".diff-btn").forEach(function (b) {
b.classList.toggle("active", +b.dataset.diff === state.difficulty);
});
renderStats();
startRound();
setTimeout(checkWhatsNew, 800);
}
document.getElementById("btnSound").addEventListener("click", function () {
state.sound = !state.sound;
this.classList.toggle("active", state.sound);
this.textContent = state.sound ? "🔊" : "🔇";
saveState();
});
document.getElementById("btnBigFont").addEventListener("click", function () {
state.bigFont = !state.bigFont;
document.body.classList.toggle("big-font", state.bigFont);
this.classList.toggle("active", state.bigFont);
saveState();
});
document
.getElementById("btnCheckin")
.addEventListener("click", showCheckinModal);
document.getElementById("closeModal").addEventListener("click", function () {
document.getElementById("checkinModal").classList.remove("show");
});
document.getElementById("checkinModal").addEventListener("click", function (e) {
if (e.target === document.getElementById("checkinModal"))
document.getElementById("checkinModal").classList.remove("show");
});
document
.getElementById("btnWrongBook")
.addEventListener("click", showWrongBookModal);
document
.getElementById("closeWrongBook")
.addEventListener("click", function () {
document.getElementById("wrongBookModal").classList.remove("show");
});
document
.getElementById("wrongBookModal")
.addEventListener("click", function (e) {
if (e.target === document.getElementById("wrongBookModal"))
document.getElementById("wrongBookModal").classList.remove("show");
});
document
.getElementById("btnStartReview")
.addEventListener("click", startReview);
document.getElementById("closeWhatsNew").addEventListener("click", function () {
document.getElementById("whatsNewModal").classList.remove("show");
localStorage.setItem("chengyu_v", APP_VERSION);
});
document
.getElementById("whatsNewModal")
.addEventListener("click", function (e) {
if (e.target === document.getElementById("whatsNewModal")) {
document.getElementById("whatsNewModal").classList.remove("show");
localStorage.setItem("chengyu_v", APP_VERSION);
}
});
document.querySelectorAll(".diff-btn").forEach(function (b) {
b.addEventListener("click", function () {
document.querySelectorAll(".diff-btn").forEach(function (x) {
x.classList.remove("active");
});
this.classList.add("active");
state.difficulty = +this.dataset.diff;
state.combo = 0;
saveState();
startRound();
});
});
document.getElementById("btnHint").addEventListener("click", useHint);
document.getElementById("btnSkip").addEventListener("click", skipQuestion);
document.getElementById("btnNext").addEventListener("click", function () {
state.round++;
document.getElementById("optLabel").style.display = "";
nextQuestion();
});
init();