first commit

This commit is contained in:
M1ngdaXie
2026-03-27 00:19:20 -07:00
commit c3f84acaef
5 changed files with 31573 additions and 0 deletions

27
README.md Normal file
View File

@@ -0,0 +1,27 @@
# 梅子的成语填字
A Chinese idiom (成语) fill-in-the-blank game made by Mingda for his mom.
## How to Play
Open `index.html` in a browser. No server or installation needed.
Choose a difficulty (简单 / 中等 / 困难), then fill in the missing character(s) of each idiom by tapping the options. Complete 5 questions per round.
## Features
- Three difficulty levels (1, 2, or 3 blanks)
- Combo streak tracking
- Wrong answer notebook (错题本) for review
- Daily check-in calendar
- Sound effects
- Large font mode
## Files
| File | Description |
|------|-------------|
| `index.html` | Main page |
| `style.css` | Styles |
| `data.js` | Idiom database (~7,000 entries) |
| `app.js` | Game logic |

828
app.js Normal file
View File

@@ -0,0 +1,828 @@
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();

29877
data.js Normal file

File diff suppressed because it is too large Load Diff

158
index.html Normal file
View File

@@ -0,0 +1,158 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<title>梅子的成语填字</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="header">
<a
href="https://m1ngdaxie.com"
class="icon-btn back-btn"
style="text-decoration: none"
>&#8592;</a
>
<div class="header-title">梅子的成语填字</div>
<div class="header-right">
<button class="icon-btn" id="btnSound">🔊</button>
<button class="icon-btn" id="btnBigFont"></button>
<button class="icon-btn" id="btnCheckin">📅</button>
</div>
</div>
<div class="info-bar">
<div class="info-item">今日&nbsp;<span id="statScore">0</span></div>
<div class="info-divider"></div>
<div class="info-item">
🔥&nbsp;<span id="statCombo">0</span>&nbsp;连击
</div>
<div class="info-divider"></div>
<div class="info-item info-wb" id="btnWrongBook">
📖&nbsp;错题本&nbsp;<span class="wb-badge" id="wbBadge">0</span>
</div>
</div>
<div style="display: none">
<span id="statTotal">0</span><span id="statStreak">0</span>
</div>
<div class="diff-control">
<button class="diff-btn active" data-diff="1">简单</button>
<button class="diff-btn" data-diff="2">中等</button>
<button class="diff-btn" data-diff="3">困难</button>
</div>
<div class="game-area">
<div class="progress-bar" id="progressBar"></div>
<div id="comboDisplay" style="display: none"></div>
<div class="idiom-display" id="idiomDisplay"></div>
<div id="optLabel" style="display: none"></div>
<div class="options-grid" id="optionsGrid"></div>
<div class="action-bar">
<button class="action-btn secondary" id="btnHint">💡 提示</button>
<button class="action-btn secondary" id="btnSkip">⏭ 跳过</button>
<button class="action-btn primary" id="btnNext" style="display: none">
下一题 →
</button>
</div>
<div id="resultArea"></div>
</div>
<div class="combo-toast" id="comboToast"></div>
<div class="modal-overlay" id="wrongBookModal">
<div class="modal">
<div
class="modal-header"
style="background: linear-gradient(135deg, #6b4f2a, #c8941a)"
>
<div class="streak-display" id="wbCount">0</div>
<div class="streak-label">道错题待复习 📖</div>
</div>
<div class="modal-body wb-body" id="wbList"></div>
<div class="wb-actions">
<button class="action-btn secondary" id="closeWrongBook">
继续答题
</button>
<button class="action-btn primary" id="btnStartReview">
练习错题 →
</button>
</div>
</div>
</div>
<div class="modal-overlay" id="whatsNewModal">
<div class="modal">
<div
class="modal-header"
style="background: linear-gradient(135deg, #2a7a52, #4caf50)"
>
<div style="font-size: 2.2rem">🎉</div>
<div
class="streak-label"
style="font-size: 1.1rem; font-weight: 900; margin-top: 6px"
>
新功能上线啦!
</div>
</div>
<div class="modal-body">
<div class="wn-item">
<span class="wn-icon">📖</span>
<div>
<b>错题本</b>
<div class="wn-desc">
答错的成语自动收录,随时可以回来专项练习,越练越强!
</div>
</div>
</div>
<div class="wn-item">
<span class="wn-icon">🎨</span>
<div>
<b>全新界面</b>
<div class="wn-desc">
更清爽的布局,手机上看更舒服,为妈妈优化 💕
</div>
</div>
</div>
</div>
<button class="close-modal" id="closeWhatsNew">
知道了,开始答题!
</button>
</div>
</div>
<div class="modal-overlay" id="checkinModal">
<div class="modal">
<div class="modal-header">
<div class="streak-display" id="modalStreak">0</div>
<div class="streak-label">天连续打卡 🌸</div>
</div>
<div class="modal-body">
<div class="daily-goal">
<div class="daily-goal-label">
今日目标:<span id="modalTodayCount">0</span> / 5 题完成
</div>
<div class="daily-goal-bar">
<div
class="daily-goal-fill"
id="modalGoalFill"
style="width: 0%"
></div>
</div>
</div>
<div class="calendar" id="modalCalendar"></div>
<p
style="font-size: 0.75rem; color: var(--text2); text-align: center"
>
每日完成5题即可打卡 ✓
</p>
</div>
<button class="close-modal" id="closeModal">关闭</button>
</div>
</div>
<script src="data.js"></script>
<script src="app.js"></script>
</body>
</html>

683
style.css Normal file
View File

@@ -0,0 +1,683 @@
@import url("https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;700;900&family=ZCOOL+KuaiLe&display=swap");
/* Fallback: if Google Fonts unavailable (offline/firewall), system CJK fonts cover all characters */
:root {
--bg: #faf5ec;
--text: #1a1008;
--text2: #6b4f2a;
--accent: #c13b0a;
--accent2: #e07b20;
--green: #2a7a52;
--green-lt: #e8f5ee;
--red: #b52020;
--red-lt: #ffebee;
--gold: #c8941a;
--card: #ffffff;
--paper-alt: #f2ead8;
--radius: 14px;
--fs-base: 16px;
--fs-char: clamp(1.9rem, 7.5vw, 2.8rem);
--fs-opt: clamp(1.3rem, 5.5vw, 1.8rem);
--shadow-sm: 0 2px 8px rgba(26, 16, 8, 0.08);
--shadow-md: 0 4px 20px rgba(26, 16, 8, 0.12);
}
.big-font {
--fs-base: 20px;
--fs-char: clamp(2.2rem, 9vw, 3.4rem);
--fs-opt: clamp(1.6rem, 7vw, 2.2rem);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
body {
font-family:
"Noto Serif SC", "PingFang SC", "Songti SC", "SimSun",
"Source Han Serif CN", serif;
background: var(--bg);
color: var(--text);
font-size: var(--fs-base);
min-height: 100dvh;
overflow-x: hidden;
}
.header {
background: #c13b0a;
color: #fff;
padding: 10px 14px;
display: flex;
align-items: center;
gap: 8px;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 2px 14px rgba(193, 59, 10, 0.4);
}
.header-title {
font-family: "ZCOOL KuaiLe", cursive;
font-size: clamp(1rem, 4vw, 1.3rem);
letter-spacing: 2px;
flex: 1;
text-align: center;
}
.header-right {
display: flex;
gap: 6px;
align-items: center;
flex-shrink: 0;
}
.icon-btn {
background: rgba(255, 255, 255, 0.18);
border: none;
color: #fff;
min-width: 44px;
min-height: 44px;
border-radius: 50%;
font-size: 1.05rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
flex-shrink: 0;
}
.icon-btn:active {
background: rgba(255, 255, 255, 0.38);
}
.icon-btn.active {
background: rgba(255, 255, 255, 0.45);
}
.back-btn {
font-size: 1.3rem;
text-decoration: none;
}
.info-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
background: var(--card);
border-bottom: 1px solid #e8d8c0;
padding: 7px 20px;
}
.info-item {
font-family: "ZCOOL KuaiLe", cursive;
font-size: 1rem;
color: var(--text2);
}
.info-item span {
color: var(--accent);
font-size: 1.1rem;
}
.info-divider {
width: 1px;
height: 16px;
background: #e0d0b8;
margin: 0 16px;
}
.info-wb {
cursor: pointer;
color: var(--accent2);
transition: opacity 0.15s;
}
.info-wb:active {
opacity: 0.6;
}
.wb-badge {
background: #fff;
color: var(--red);
border: 1.5px solid var(--red);
border-radius: 10px;
padding: 1px 6px;
font-size: 0.8rem;
font-weight: 900;
font-family: "Noto Serif SC", serif;
margin-left: 4px;
vertical-align: middle;
display: inline-block;
text-align: center;
}
.wb-badge.empty {
background: transparent;
border-color: #c8b89a;
color: #c8b89a;
}
.wb-body {
max-height: 52vh;
overflow-y: auto;
padding: 10px 16px;
}
.wb-item {
padding: 12px 0;
border-bottom: 1px solid #f0e4d0;
}
.wb-item:last-child {
border-bottom: none;
}
.wb-word {
font-size: 1.4rem;
font-weight: 900;
letter-spacing: 3px;
color: var(--text);
}
.wb-pinyin {
font-size: 0.78rem;
color: var(--text2);
margin: 2px 0 6px;
}
.wb-explain {
font-size: 0.85rem;
color: #5d4e37;
line-height: 1.6;
background: #fff8f0;
border-radius: 6px;
padding: 8px 10px;
border-left: 3px solid var(--accent2);
}
.wb-meta {
font-size: 0.75rem;
color: var(--red);
margin-top: 6px;
font-family: "Noto Serif SC", serif;
}
.wb-empty {
text-align: center;
padding: 32px 16px;
color: var(--text2);
font-size: 1rem;
}
.wb-actions {
display: flex;
gap: 10px;
padding: 12px 16px;
border-top: 1px solid #f0e4d0;
}
.wb-actions .action-btn {
flex: 1;
padding: 12px 0;
text-align: center;
}
.wn-item {
display: flex;
gap: 14px;
align-items: flex-start;
padding: 12px 0;
border-bottom: 1px solid #f0e4d0;
}
.wn-item:last-child {
border-bottom: none;
}
.wn-icon {
font-size: 1.6rem;
flex-shrink: 0;
margin-top: 2px;
}
.wn-desc {
font-size: 0.85rem;
color: var(--text2);
margin-top: 3px;
line-height: 1.6;
}
.diff-control {
display: flex;
background: var(--paper-alt);
border-bottom: 1px solid #ddd0b8;
padding: 8px 16px;
gap: 8px;
justify-content: center;
}
.diff-btn {
flex: 1;
max-width: 110px;
border: none;
background: transparent;
color: var(--text2);
padding: 7px 0;
border-radius: 20px;
font-size: 0.9rem;
font-family: "Noto Serif SC", serif;
cursor: pointer;
transition: all 0.2s;
font-weight: 700;
min-height: 38px;
}
.diff-btn.active {
background: var(--accent);
color: #fff;
box-shadow: 0 2px 8px rgba(193, 59, 10, 0.3);
}
.game-area {
padding: 16px;
max-width: 480px;
margin: 0 auto;
}
.progress-bar {
display: flex;
gap: 4px;
margin-bottom: 20px;
height: 6px;
}
.progress-dot {
flex: 1;
border-radius: 3px;
height: 6px;
background: #e0d0b8;
transition: background 0.3s;
}
.progress-dot.done {
background: var(--green);
}
.progress-dot.current {
background: var(--accent);
box-shadow: 0 0 8px rgba(193, 59, 10, 0.45);
}
.progress-dot.skip {
background: var(--red);
opacity: 0.55;
}
.idiom-display {
display: flex;
justify-content: center;
gap: clamp(6px, 2.5vw, 14px);
margin-bottom: 28px;
position: relative;
}
.char-box {
width: clamp(58px, 17vw, 80px);
height: clamp(58px, 17vw, 80px);
background: var(--card);
border: 2.5px solid #e0d0b8;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--fs-char);
font-weight: 900;
color: var(--text);
box-shadow: var(--shadow-sm);
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
position: relative;
background-image:
linear-gradient(rgba(160, 130, 90, 0.07) 1px, transparent 1px),
linear-gradient(90deg, rgba(160, 130, 90, 0.07) 1px, transparent 1px);
background-size: 50% 50%;
}
.char-box.blank {
border-color: var(--accent);
border-style: dashed;
background-color: #fff5ee;
background-image:
linear-gradient(rgba(193, 59, 10, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(193, 59, 10, 0.05) 1px, transparent 1px);
background-size: 50% 50%;
animation: blankPulse 2.5s ease-in-out infinite;
cursor: pointer;
}
.char-box.blank.selected {
border-style: solid;
border-width: 3px;
background-color: #ffeedd;
transform: scale(1.1);
box-shadow: 0 0 0 4px rgba(193, 59, 10, 0.18);
animation: none;
}
.char-box.blank.selected::after {
content: "";
position: absolute;
bottom: -9px;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--accent);
}
.char-box.filled {
border-color: var(--accent2);
border-style: solid;
background: linear-gradient(135deg, #fff5ee, #ffe8d0);
color: var(--accent);
animation: popIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.char-box.correct {
border-color: var(--green);
background: linear-gradient(135deg, #e8f5ee, #d4eddf);
color: var(--green);
animation: correctPop 0.4s ease;
}
.char-box.wrong {
border-color: var(--red);
background: var(--red-lt);
color: var(--red);
animation: shake 0.4s ease;
}
.char-box .pinyin-hint {
position: absolute;
top: -20px;
font-size: 0.65rem;
color: var(--text2);
font-weight: 400;
white-space: nowrap;
}
.options-grid {
display: grid;
grid-template-columns: repeat(4, clamp(52px, 13vw, 68px));
gap: clamp(6px, 2vw, 10px);
margin-bottom: 16px;
justify-content: center;
}
.option-btn {
aspect-ratio: 1;
width: 100%;
border: 2px solid #e0d0b8;
background: var(--card);
border-radius: 10px;
font-size: var(--fs-opt);
font-family: "Noto Serif SC", serif;
font-weight: 700;
color: var(--text);
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-sm);
}
.option-btn:active {
transform: scale(0.88);
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.option-btn.used {
opacity: 0.28;
pointer-events: none;
background: #f5f0e8;
}
.option-btn.disabled {
opacity: 0.15;
pointer-events: none;
}
.action-bar {
display: flex;
gap: 10px;
justify-content: center;
margin-bottom: 16px;
flex-wrap: wrap;
}
.action-btn {
border: none;
padding: 12px 28px;
border-radius: 24px;
font-size: 0.95rem;
font-family: "Noto Serif SC", serif;
cursor: pointer;
transition: all 0.2s;
min-height: 48px;
}
.action-btn.primary {
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: #fff;
box-shadow: 0 2px 10px rgba(193, 59, 10, 0.3);
flex: 1;
max-width: 280px;
}
.action-btn.primary:active {
transform: scale(0.97);
}
.action-btn.secondary {
background: var(--card);
color: var(--text2);
border: 1.5px solid #e0d0b8;
}
.action-btn.secondary:active {
transform: scale(0.95);
}
.result-card {
background: var(--card);
border-radius: var(--radius);
padding: 20px;
margin-top: 12px;
box-shadow: var(--shadow-md);
animation: slideUp 0.4s ease;
border-top: 4px solid var(--green);
}
.result-card.wrong-result {
border-top-color: var(--red);
}
.result-card h3 {
font-size: 1.15rem;
margin-bottom: 8px;
font-family: "ZCOOL KuaiLe", cursive;
}
.result-card .idiom-word {
font-size: clamp(1.6rem, 6vw, 2.2rem);
font-weight: 900;
color: var(--text);
margin-bottom: 4px;
letter-spacing: 4px;
}
.result-card .idiom-pinyin {
font-size: 0.85rem;
color: var(--text2);
margin-bottom: 12px;
letter-spacing: 1px;
}
.result-card .idiom-explain {
font-size: 0.95rem;
line-height: 1.8;
color: #5d4e37;
padding: 12px 12px 12px 16px;
background: #fff8f0;
border-radius: 8px;
border-left: 3px solid var(--accent2);
}
.combo-toast {
position: fixed;
top: 72px;
left: 50%;
transform: translateX(-50%) translateY(-8px);
background: var(--gold);
color: #fff;
border-radius: 24px;
padding: 7px 20px;
font-family: "ZCOOL KuaiLe", cursive;
font-size: 1.05rem;
box-shadow: var(--shadow-md);
opacity: 0;
pointer-events: none;
z-index: 150;
transition:
opacity 0.25s,
transform 0.25s;
white-space: nowrap;
}
.combo-toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: none;
align-items: center;
justify-content: center;
z-index: 200;
padding: 20px;
}
.modal-overlay.show {
display: flex;
}
.modal {
background: var(--card);
border-radius: 20px;
overflow: hidden;
max-width: 360px;
width: 100%;
animation: slideUp 0.3s ease;
box-shadow: 0 8px 32px rgba(26, 16, 8, 0.2);
}
.modal-header {
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: #fff;
padding: 22px 24px 18px;
text-align: center;
}
.modal-header .streak-display {
font-size: 3.5rem;
font-weight: 900;
font-family: "ZCOOL KuaiLe", cursive;
line-height: 1;
}
.modal-header .streak-label {
font-size: 0.9rem;
opacity: 0.92;
margin-top: 4px;
}
.modal-body {
padding: 18px 20px 20px;
}
.modal .calendar {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 14px;
}
.modal .cal-day {
width: 100%;
aspect-ratio: 1;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.72rem;
background: var(--paper-alt);
color: var(--text2);
}
.modal .cal-day.checked {
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: #fff;
font-weight: 700;
}
.modal .cal-day.today {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.daily-goal {
background: var(--paper-alt);
border-radius: 10px;
padding: 10px 14px;
margin-bottom: 14px;
}
.daily-goal-label {
font-size: 0.82rem;
color: var(--text2);
margin-bottom: 7px;
}
.daily-goal-bar {
height: 8px;
background: #ddd0b8;
border-radius: 4px;
overflow: hidden;
}
.daily-goal-fill {
height: 100%;
background: linear-gradient(90deg, var(--green), #4caf50);
border-radius: 4px;
transition: width 0.5s ease;
}
.close-modal {
display: block;
width: 100%;
border: none;
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: #fff;
padding: 14px;
font-size: 1rem;
font-family: "Noto Serif SC", serif;
cursor: pointer;
min-height: 50px;
}
@keyframes popIn {
0% {
transform: scale(0.8);
opacity: 0.5;
}
100% {
transform: scale(1);
opacity: 1;
}
}
@keyframes correctPop {
0% {
transform: scale(1);
}
50% {
transform: scale(1.18);
}
100% {
transform: scale(1);
}
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-7px);
}
75% {
transform: translateX(7px);
}
}
@keyframes slideUp {
0% {
transform: translateY(20px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes blankPulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(193, 59, 10, 0.08);
}
50% {
box-shadow: 0 0 0 6px rgba(193, 59, 10, 0.13);
}
}
@keyframes comboIn {
0% {
transform: scale(0.5);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
@media (max-width: 380px) {
.header-title {
font-size: 0.95rem;
letter-spacing: 1px;
}
.icon-btn {
min-width: 40px;
min-height: 40px;
}
}
@media (min-width: 600px) {
.game-area {
padding: 24px 32px;
}
.char-box {
width: 88px;
height: 88px;
}
.options-grid {
gap: 16px;
}
}