Files
chengyu/app.js
2026-03-27 00:19:20 -07:00

829 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = '<div class="wb-empty">🎉 暂无错题,梅子很棒!</div>';
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 =
'<div class="wb-word">' +
item.w +
'</div><div class="wb-pinyin">' +
item.p +
'</div><div class="wb-explain">' +
item.e +
'</div><div class="wb-meta">错误 ' +
item.count +
" 次</div>";
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 = '<span class="combo-text">' + txt + "</span>";
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 =
'<div style="margin-top:12px;padding:10px;background:linear-gradient(135deg,#FFF8E1,#FFECB3);border-radius:10px;text-align:center"><span style="font-size:1.2rem">🎉</span> <b>梅子今日打卡成功!</b> 连续 ' +
state.streak +
" 天</div>";
var pg = "";
if (!nc && state.todayCount < DAILY_GOAL)
pg =
'<div style="margin-top:8px;font-size:.8rem;color:var(--text2)">今日进度:' +
state.todayCount +
"/" +
DAILY_GOAL +
" 题(完成" +
DAILY_GOAL +
"题即可打卡)</div>";
a.innerHTML =
'<div class="result-card' +
(ok ? "" : " wrong-result") +
'"><h3 style="color:' +
(ok ? "var(--green)" : "var(--red)") +
'">' +
(ok ? "✅ 回答正确!" : "❌ 答错了~") +
(ok && state.combo >= 3 ? " 🔥" : "") +
'</h3><div class="idiom-word">' +
idiom.w +
'</div><div class="idiom-pinyin">' +
idiom.p +
'</div><div class="idiom-explain"><b>释义:</b>' +
idiom.e +
"</div>" +
ch +
pg +
"</div>";
}
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
? '<button class="action-btn secondary" onclick="showWrongBookModal()" style="margin-top:8px;width:100%">再次练习错题 📖</button>'
: "";
document.getElementById("resultArea").innerHTML =
'<div class="result-card" style="border-top-color:var(--gold);text-align:center"><h3 style="color:var(--gold)">' +
(wasReview ? "📖 错题练习结束!" : "🏆 本轮结束!") +
'</h3><div style="font-size:2rem;font-weight:900;color:var(--accent);margin:12px 0;font-family:ZCOOL KuaiLe,cursive">' +
c +
" / " +
ROUND_SIZE +
'</div><div style="color:var(--text2);margin-bottom:16px">' +
msg +
'</div><button class="action-btn primary" onclick="startRound()" style="font-size:1rem;padding:12px 0;width:100%">再来一轮 →</button>' +
reviewBtn +
"</div>";
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();