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, theme: "ink", 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, theme: state.theme, 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.theme = s.theme || "ink"; 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 && !state.usedIdiomIndices[i.w]; }); if (!pool.length) { state.usedIdiomIndices = {}; pool = IDIOMS.filter(function (i) { return i.l <= state.difficulty; }); } var item = pool[Math.floor(Math.random() * pool.length)]; state.usedIdiomIndices[item.w] = 1; return item; } 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; var flame = document.getElementById("flameIcon"); flame.className = ""; if (state.combo >= 8) flame.className = "flame-high"; else if (state.combo >= 5) flame.className = "flame-mid"; else if (state.combo >= 3) flame.className = "flame-low"; } 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 = []; state.usedIdiomIndices = {}; 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 ? [ "✅ 回答正确!", "✅ 太棒了!", "✅ 梅子答对了!", "✅ 完全正确!", "✅ 厉害!", "✅ 对!梅子真棒!", ][Math.floor(Math.random() * 6)] : [ "❌ 答错了~", "❌ 没关系,记住它!", "❌ 这次没中,下次加油!", "❌ 差一点点~", ][Math.floor(Math.random() * 4)]) + (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 ? [ "满分!梅子太厉害了!🎉", "完美!梅子真是成语达人!✨", "全对!梅子今天状态绝佳!🌟", "漂亮!一题不差,梅子威武!👏", ][Math.floor(Math.random() * 4)] : c >= 3 ? [ "梅子表现不错!继续加油~", "答得很好,梅子越来越厉害了!", "不错不错,梅子进步了!🌸", "梅子答题越来越顺了,加油!", ][Math.floor(Math.random() * 4)] : [ "没关系梅子,多练习就会进步!💪", "继续努力,梅子肯定能行!", "不怕不怕,熟能生巧!加油梅子~", "错了没关系,下次梅子一定更好!", ][Math.floor(Math.random() * 4)]; 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(); document.documentElement.setAttribute("data-theme", state.theme); document.getElementById("btnSound").classList.toggle("active", state.sound); document.getElementById("btnSound").textContent = state.sound ? "🔊" : "🔇"; document.getElementById("btnTheme").textContent = state.theme === "ink" ? "🌙" : "☀️"; showThemeTooltip(); 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(); }); function showThemeTooltip() { if (localStorage.getItem("chengyu_theme_tip")) return; var tip = document.getElementById("themeTooltip"); if (!tip) return; setTimeout(function () { tip.classList.add("show"); var timer = setTimeout(dismissThemeTooltip, 5000); tip.addEventListener("click", function () { clearTimeout(timer); dismissThemeTooltip(); }, { once: true }); }, 1200); } function dismissThemeTooltip() { var tip = document.getElementById("themeTooltip"); if (tip) tip.classList.remove("show"); localStorage.setItem("chengyu_theme_tip", "1"); } document.getElementById("btnTheme").addEventListener("click", function () { dismissThemeTooltip(); state.theme = state.theme === "ink" ? "paper" : "ink"; document.documentElement.setAttribute("data-theme", state.theme); this.textContent = state.theme === "ink" ? "🌙" : "☀️"; 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();