對戰 · Song Đấu HSK5
by PhantomWolf631176 lines43.2 KB
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>對戰 · Song Đấu HSK5</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@500;700;900&family=Be+Vietnam+Pro:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
:root{
--ink-0:#141210;
--ink-1:#1c1916;
--ink-2:#26221d;
--paper:#eee4cd;
--paper-dim:#c9bea3;
--seal:#b6351f;
--seal-bright:#d9432a;
--gold:#c9a227;
--gold-dim:#8a7020;
--ok:#5f8a4c;
--bad:#b6351f;
--radius:14px;
}
*{box-sizing:border-box;}
body{
margin:0;
background:
radial-gradient(1200px 600px at 15% -10%, #2a251e 0%, transparent 60%),
radial-gradient(1000px 500px at 110% 10%, #241f19 0%, transparent 55%),
var(--ink-0);
color:var(--paper);
font-family:'Be Vietnam Pro', sans-serif;
min-height:100vh;
display:flex;
justify-content:center;
padding:32px 16px 64px;
}
.wrap{width:100%;max-width:640px;}
.hanzi{font-family:'Noto Serif SC', serif;}
.mono{font-family:'Space Mono', monospace;}
.grain{
position:fixed;inset:0;pointer-events:none;opacity:.05;mix-blend-mode:overlay;
background-image:radial-gradient(circle at 1px 1px, #fff 1px, transparent 0);
background-size:3px 3px;
}
.screen{display:none;}
.screen.active{display:block;animation:fadein .4s ease both;}
@keyframes fadein{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:translateY(0);}}
/* ---------- START SCREEN ---------- */
.title-block{text-align:center;margin:12px 0 36px;}
.seal-mark{
width:64px;height:64px;border-radius:50%;
border:3px solid var(--seal);
display:flex;align-items:center;justify-content:center;
margin:0 auto 18px;
color:var(--seal);
font-family:'Noto Serif SC', serif;
font-size:28px;font-weight:900;
transform:rotate(-6deg);
}
h1.hanzi{
font-size:44px;margin:0 0 6px;font-weight:900;letter-spacing:2px;color:var(--paper);
}
.subtitle{
color:var(--paper-dim);font-size:15px;letter-spacing:.5px;margin:0;
}
.card-block{
background:var(--ink-1);
border:1px solid #3a352c;
border-radius:var(--radius);
padding:24px;
margin-bottom:20px;
}
.field-label{
font-size:12px;text-transform:uppercase;letter-spacing:1.5px;color:var(--gold);margin-bottom:8px;display:block;
}
input[type=text]{
width:100%;
background:var(--ink-2);
border:1px solid #453f34;
border-radius:10px;
padding:13px 14px;
color:var(--paper);
font-family:'Be Vietnam Pro', sans-serif;
font-size:16px;
outline:none;
transition:border-color .2s;
}
input[type=text]:focus{border-color:var(--gold-dim);}
input[type=text]::placeholder{color:#6b6455;}
.two-col{display:grid;grid-template-columns:1fr 1fr;gap:14px;}
.two-col.single{grid-template-columns:1fr;}
@media(max-width:480px){.two-col{grid-template-columns:1fr;}}
.pill-row{display:flex;gap:10px;flex-wrap:wrap;margin-top:6px;}
.pill{
flex:1;min-width:90px;text-align:center;
padding:12px 10px;border-radius:10px;
border:1px solid #453f34;background:var(--ink-2);
color:var(--paper-dim);cursor:pointer;font-size:14px;font-weight:500;
transition:all .15s;
}
.pill:hover{border-color:var(--gold-dim);}
.pill.selected{
background:rgba(201,162,39,.13);
border-color:var(--gold);color:var(--gold);
}
.btn-seal{
display:block;width:100%;
background:var(--seal);
color:var(--paper);
border:none;border-radius:12px;
padding:16px;
font-family:'Be Vietnam Pro', sans-serif;
font-size:16px;font-weight:700;letter-spacing:.5px;
cursor:pointer;
transition:transform .12s, background .15s;
}
.btn-seal:hover{background:var(--seal-bright);}
.btn-seal:active{transform:scale(.98);}
.btn-seal:disabled{background:#5b4c42;color:#8a8072;cursor:not-allowed;}
.hint-line{color:var(--paper-dim);font-size:13px;text-align:center;margin-top:14px;line-height:1.6;}
.reset-link-row{text-align:center;margin-top:16px;}
.reset-link{color:var(--paper-dim);font-size:12px;text-decoration:underline;cursor:pointer;}
.reset-link:hover{color:var(--gold);}
.reset-link-sep{color:var(--paper-dim);font-size:12px;margin:0 4px;}
/* ---------- GAME SCREEN ---------- */
.scoreboard{
display:grid;grid-template-columns:1fr auto 1fr;
align-items:center;gap:10px;margin-bottom:22px;
}
.scoreboard.solo{grid-template-columns:1fr;justify-items:center;}
.scoreboard.solo .player-tag{text-align:center;min-width:220px;}
.player-tag{
padding:12px 14px;border-radius:10px;
border:1px solid #3a352c;background:var(--ink-1);
transition:all .2s;
}
.player-tag.active{
border-color:var(--gold);
background:rgba(201,162,39,.09);
box-shadow:0 0 0 1px rgba(201,162,39,.25) inset;
}
.player-tag.p2{text-align:right;}
.player-name{font-size:13px;color:var(--paper-dim);margin:0 0 4px;font-weight:500;}
.player-tag.active .player-name{color:var(--gold);}
.player-score{font-size:24px;margin:0;font-weight:700;}
.vs-mark{font-size:12px;color:var(--paper-dim);letter-spacing:1px;text-align:center;}
.round-count{font-size:11px;color:var(--paper-dim);margin-top:2px;}
.turn-label{
font-size:12px;text-transform:uppercase;letter-spacing:1.5px;color:var(--gold);
margin:0 0 14px;text-align:center;
}
.timer-track{
height:3px;background:#332e26;border-radius:3px;overflow:hidden;margin-bottom:26px;
}
.timer-fill{
height:100%;background:var(--gold);width:100%;
transition:width .1s linear;
}
.timer-fill.low{background:var(--seal);}
.word-stage{
position:relative;
text-align:center;
padding:36px 20px 30px;
margin-bottom:22px;
border:1px solid #3a352c;
border-radius:var(--radius);
background:var(--ink-1);
min-height:170px;
display:flex;flex-direction:column;justify-content:center;
overflow:hidden;
}
.word-hanzi{
font-family:'Noto Serif SC', serif;
font-size:64px;font-weight:700;color:var(--paper);margin:0;line-height:1.1;
}
.word-pinyin{
font-family:'Space Mono', monospace;
font-size:16px;color:var(--paper-dim);
margin-top:10px;min-height:22px;
opacity:0;transition:opacity .5s ease;
}
.word-pinyin.show{opacity:1;}
.seal-stamp{
position:absolute;top:14px;right:18px;
width:58px;height:58px;border-radius:50%;
border:3px solid var(--ok);
display:flex;align-items:center;justify-content:center;
font-family:'Noto Serif SC', serif;font-weight:900;font-size:22px;color:var(--ok);
transform:rotate(14deg) scale(0);
opacity:0;
}
.seal-stamp.stamp-in{
animation:stampdown .35s cubic-bezier(.2,1.4,.5,1) forwards;
}
.seal-stamp.wrong{border-color:var(--bad);color:var(--bad);}
@keyframes stampdown{
0%{transform:rotate(14deg) scale(2.2);opacity:0;}
60%{transform:rotate(10deg) scale(.92);opacity:1;}
100%{transform:rotate(10deg) scale(1);opacity:1;}
}
.passage-stage{
text-align:left;
padding:28px 24px;
margin-bottom:22px;
border:1px solid #3a352c;
border-radius:var(--radius);
background:var(--ink-1);
}
.passage-progress{font-size:11px;color:var(--paper-dim);margin:0 0 14px;text-transform:uppercase;letter-spacing:1px;}
.passage-text{
font-family:'Noto Serif SC', serif;
font-size:22px;line-height:2;color:var(--paper);
}
@media(max-width:480px){.passage-text{font-size:18px;line-height:1.9;}}
.blank-slot{
display:inline-block;min-width:46px;text-align:center;
border-bottom:2px solid var(--gold);color:var(--gold);
padding:0 4px;font-weight:700;
}
.blank-slot.current{background:rgba(201,162,39,.15);border-radius:4px;}
.blank-slot.correct{border-color:var(--ok);color:var(--ok);background:rgba(95,138,76,.12);border-radius:4px;}
.blank-slot.wrong{border-color:var(--bad);color:var(--bad);background:rgba(182,53,31,.12);border-radius:4px;}
.explain-toggle{
background:transparent;border:1px solid #453f34;border-radius:8px;
padding:9px 14px;color:var(--gold);font-family:'Be Vietnam Pro',sans-serif;
font-size:13px;cursor:pointer;margin-right:10px;
}
.explain-toggle:hover{border-color:var(--gold);}
.explain-box{
margin-top:14px;padding:13px 15px;border-radius:10px;
background:rgba(201,162,39,.08);border:1px solid rgba(201,162,39,.25);
font-size:14px;line-height:1.6;color:var(--paper-dim);
}
.answers{display:grid;grid-template-columns:1fr 1fr;gap:12px;}
@media(max-width:480px){.answers{grid-template-columns:1fr;}}
.answer-btn{
text-align:left;
background:var(--ink-1);
border:1px solid #3a352c;
border-radius:10px;
padding:15px 16px;
color:var(--paper);
font-family:'Be Vietnam Pro', sans-serif;
font-size:15px;
cursor:pointer;
transition:all .15s;
}
.answer-btn.centered{text-align:center;font-size:20px;}
.answer-btn:hover:not(:disabled){border-color:var(--gold-dim);background:var(--ink-2);}
.answer-btn:disabled{cursor:default;}
.answer-btn.correct{border-color:var(--ok);background:rgba(95,138,76,.15);color:#a9d194;}
.answer-btn.wrong{border-color:var(--bad);background:rgba(182,53,31,.15);color:#e6a091;}
.answer-btn.faded{opacity:.45;}
.next-row{margin-top:20px;text-align:center;min-height:44px;display:flex;justify-content:center;gap:10px;flex-wrap:wrap;}
.next-row .btn-seal{width:auto;min-width:160px;}
.streak-note{font-size:13px;color:var(--gold);margin-top:10px;text-align:center;min-height:18px;}
/* ---------- END SCREEN ---------- */
.end-seal{
width:104px;height:104px;border-radius:50%;
border:4px solid var(--gold);
display:flex;align-items:center;justify-content:center;flex-direction:column;
margin:0 auto 22px;color:var(--gold);
transform:rotate(-5deg);
}
.end-seal .hanzi{font-size:34px;font-weight:900;line-height:1;}
.end-title{text-align:center;font-size:22px;font-weight:700;margin:0 0 4px;}
.end-sub{text-align:center;color:var(--paper-dim);font-size:14px;margin:0 0 28px;}
.final-scores{display:flex;gap:14px;margin-bottom:24px;}
.final-scores.solo{justify-content:center;}
.final-scores.solo .final-card{flex:0 1 260px;}
.final-card{
flex:1;text-align:center;padding:20px 12px;
border-radius:12px;border:1px solid #3a352c;background:var(--ink-1);
}
.final-card.winner{border-color:var(--gold);background:rgba(201,162,39,.09);}
.final-card p{margin:0;}
.final-card .fname{font-size:13px;color:var(--paper-dim);margin-bottom:6px;}
.final-card .fscore{font-size:30px;font-weight:700;}
.final-card.winner .fname{color:var(--gold);}
.btn-ghost{
display:block;width:100%;text-align:center;
background:transparent;border:1px solid #453f34;border-radius:12px;
padding:14px;color:var(--paper-dim);font-family:'Be Vietnam Pro',sans-serif;
font-size:14px;cursor:pointer;margin-top:10px;
}
.btn-ghost:hover{border-color:var(--gold-dim);color:var(--paper);}
.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);}
</style>
</head>
<body>
<div class="grain"></div>
<div class="wrap">
<h2 class="sr-only">Trò chơi học từ vựng và đọc hiểu tiếng Trung HSK5, chơi một mình hoặc hai người, gồm chế độ đấu từ vựng trắc nghiệm và chế độ đọc đoạn văn chọn từ điền vào chỗ trống kèm giải thích đáp án.</h2>
<!-- START SCREEN -->
<section class="screen active" id="screen-start">
<div class="title-block">
<div class="seal-mark hanzi">戰</div>
<h1 class="hanzi">对战 HSK5</h1>
<p class="subtitle">Từ vựng và đọc hiểu — chơi một mình hoặc cùng bạn</p>
</div>
<div class="card-block">
<label class="field-label">Số người chơi</label>
<div class="pill-row" id="player-picker">
<div class="pill" data-players="1">1 người</div>
<div class="pill selected" data-players="2">2 người</div>
</div>
</div>
<div class="card-block">
<label class="field-label">Chế độ chơi</label>
<div class="pill-row" id="mode-picker">
<div class="pill selected" data-mode="vocab">Đấu từ vựng</div>
<div class="pill" data-mode="passage">Đọc đoạn văn</div>
</div>
</div>
<div class="card-block">
<label class="field-label">Người chơi</label>
<div class="two-col" id="names-two-col">
<div>
<input type="text" id="p1name" placeholder="Người chơi 1" maxlength="16">
</div>
<div id="p2-field-wrap">
<input type="text" id="p2name" placeholder="Người chơi 2" maxlength="16">
</div>
</div>
</div>
<div class="card-block" id="round-picker-card">
<label class="field-label">Số câu mỗi người</label>
<div class="pill-row" id="round-picker">
<div class="pill" data-rounds="6">6 câu</div>
<div class="pill selected" data-rounds="10">10 câu</div>
<div class="pill" data-rounds="15">15 câu</div>
</div>
</div>
<div class="card-block" id="passage-picker-card" style="display:none;">
<label class="field-label">Số đoạn văn</label>
<div class="pill-row" id="passage-picker">
<div class="pill selected" data-count="1">1 đoạn (5 câu)</div>
<div class="pill" data-count="2">2 đoạn (10 câu)</div>
<div class="pill" data-count="4">4 đoạn (20 câu)</div>
</div>
</div>
<button class="btn-seal" id="btn-start">Bắt đầu</button>
<p class="hint-line">Trả lời lần lượt theo lượt chơi. Chế độ Đấu từ vựng có 15 giây mỗi câu, pinyin tự hiện sau 7 giây. Chế độ Đọc đoạn văn không giới hạn thời gian và có thể xem giải thích cho mỗi đáp án.</p>
<div id="vocab-status-block">
<p class="hint-line" id="sheet-status" style="margin-top:6px;"></p>
<p class="hint-line" id="queue-status" style="margin-top:2px;"></p>
<p class="reset-link-row">
<span class="reset-link" id="btn-sync-sheet">Làm mới từ Google Sheet</span><span class="reset-link-sep">·</span><span class="reset-link" id="btn-reset-queue">Đặt lại vòng từ vựng</span>
</p>
</div>
</section>
<!-- GAME SCREEN -->
<section class="screen" id="screen-game">
<div class="scoreboard">
<div class="player-tag p1" id="tag-p1">
<p class="player-name" id="name-p1-display">Người chơi 1</p>
<p class="player-score mono" id="score-p1">0</p>
</div>
<div>
<div class="vs-mark">对</div>
<div class="round-count mono" id="round-count">1 / 20</div>
</div>
<div class="player-tag p2" id="tag-p2">
<p class="player-name" id="name-p2-display">Người chơi 2</p>
<p class="player-score mono" id="score-p2">0</p>
</div>
</div>
<p class="turn-label" id="turn-label">Lượt của Người chơi 1</p>
<div class="timer-track" id="timer-track"><div class="timer-fill" id="timer-fill"></div></div>
<div class="word-stage" id="word-stage">
<div class="seal-stamp" id="seal-stamp"></div>
<p class="word-hanzi hanzi" id="word-hanzi">例如</p>
<p class="word-pinyin mono" id="word-pinyin">lìrú</p>
</div>
<div class="passage-stage" id="passage-stage" style="display:none;">
<p class="passage-progress mono" id="passage-progress">Đoạn 1/1 · Câu 1/5</p>
<div class="passage-text hanzi" id="passage-text"></div>
<div class="explain-box" id="explain-box" style="display:none;"></div>
</div>
<div class="answers" id="answers"></div>
<p class="streak-note" id="streak-note"></p>
<div class="next-row" id="next-row"></div>
</section>
<!-- END SCREEN -->
<section class="screen" id="screen-end">
<div class="end-seal">
<span class="hanzi">冠</span>
</div>
<h2 class="end-title" id="end-title">Người chơi 1 thắng!</h2>
<p class="end-sub" id="end-sub">Trận đấu kết thúc</p>
<div class="final-scores" id="final-scores">
<div class="final-card" id="final-p1">
<p class="fname" id="final-name-p1">Người chơi 1</p>
<p class="fscore mono" id="final-score-p1">0</p>
</div>
<div class="final-card" id="final-p2">
<p class="fname" id="final-name-p2">Người chơi 2</p>
<p class="fscore mono" id="final-score-p2">0</p>
</div>
</div>
<button class="btn-seal" id="btn-again">Chơi lại</button>
<button class="btn-ghost" id="btn-menu">Về màn hình chính</button>
</section>
</div>
<script>
/* ============ DỮ LIỆU TỪ VỰNG (mặc định có sẵn trong app) ============ */
const WORDS = [
["例如","lìrú","ví dụ như"],
["尽管","jǐnguǎn","mặc dù"],
["采取","cǎiqǔ","áp dụng, thực hiện"],
["趋势","qūshì","xu hướng"],
["证明","zhèngmíng","chứng minh"],
["理论","lǐlùn","lý luận"],
["态度","tàidù","thái độ"],
["意识","yìshí","ý thức"],
["权利","quánlì","quyền lợi"],
["规律","guīlǜ","quy luật"],
["现象","xiànxiàng","hiện tượng"],
["传统","chuántǒng","truyền thống"],
["习惯","xíguàn","thói quen"],
["风险","fēngxiǎn","rủi ro"],
["危险","wēixiǎn","nguy hiểm"],
["保护","bǎohù","bảo vệ"],
["反映","fǎnyìng","phản ánh"],
["导致","dǎozhì","dẫn đến"],
["引起","yǐnqǐ","gây ra, dẫn đến"],
["推荐","tuījiàn","đề xuất, giới thiệu"],
["推迟","tuīchí","hoãn lại"],
["促进","cùjìn","thúc đẩy"],
["阻止","zǔzhǐ","ngăn chặn"],
["限制","xiànzhì","hạn chế"],
["范围","fànwéi","phạm vi"],
["方式","fāngshì","phương thức"],
["途径","tújìng","con đường, cách thức"],
["来源","láiyuán","nguồn gốc"],
["依据","yījù","căn cứ"],
["持续","chíxù","kéo dài liên tục"],
["逐渐","zhújiàn","dần dần"],
["不断","búduàn","không ngừng"],
["始终","shǐzhōng","luôn luôn, từ đầu đến cuối"],
["一致","yízhì","nhất trí, thống nhất"],
["组成","zǔchéng","hợp thành"],
["形成","xíngchéng","hình thành"],
["表达","biǎodá","biểu đạt"],
["表现","biǎoxiàn","biểu hiện"],
["展示","zhǎnshì","trưng bày, thể hiện"],
["呈现","chéngxiàn","hiện ra, thể hiện"],
["情绪","qíngxù","cảm xúc"],
["心态","xīntài","tâm thái, tâm trạng"],
["思考","sīkǎo","suy nghĩ, tư duy"],
["考虑","kǎolǜ","cân nhắc, xem xét"],
["评价","píngjià","đánh giá"],
["判断","pànduàn","phán đoán"],
["坚持","jiānchí","kiên trì"],
["承受","chéngshòu","chịu đựng"],
["耐心","nàixīn","kiên nhẫn"],
["毅力","yìlì","nghị lực, ý chí"],
["勇气","yǒngqì","dũng khí, can đảm"],
["机遇","jīyù","cơ hội, thời cơ"],
["场合","chǎnghé","trường hợp, hoàn cảnh"],
["情景","qíngjǐng","tình cảnh, khung cảnh"],
["竞争","jìngzhēng","cạnh tranh"],
["合作","hézuò","hợp tác"],
["沟通","gōutōng","giao tiếp, trao đổi"],
["交流","jiāoliú","giao lưu"],
["分享","fēnxiǎng","chia sẻ"],
["尊重","zūnzhòng","tôn trọng"],
["事实","shìshí","sự thật, thực tế"],
["现实","xiànshí","hiện thực"],
["现状","xiànzhuàng","hiện trạng"],
["原因","yuányīn","nguyên nhân"],
["原则","yuánzé","nguyên tắc"],
["原来","yuánlái","vốn dĩ, hóa ra"],
["结构","jiégòu","kết cấu, cấu trúc"],
["结果","jiéguǒ","kết quả"],
["结论","jiélùn","kết luận"],
["目标","mùbiāo","mục tiêu"],
["目的","mùdì","mục đích"],
["目前","mùqián","hiện tại, trước mắt"],
["因素","yīnsù","nhân tố, yếu tố"],
["因此","yīncǐ","vì vậy, do đó"],
["相反","xiāngfǎn","trái lại, ngược lại"],
["相应","xiāngyìng","tương ứng"],
["相当","xiāngdāng","tương đối, khá là"],
["相关","xiāngguān","liên quan"],
["显然","xiǎnrán","rõ ràng, hiển nhiên"],
["显示","xiǎnshì","hiển thị, cho thấy"],
["明显","míngxiǎn","rõ ràng"],
["强调","qiángdiào","nhấn mạnh"],
["强烈","qiángliè","mạnh mẽ, mãnh liệt"],
["广泛","guǎngfàn","rộng rãi, phổ biến"],
["普遍","pǔbiàn","phổ biến"],
["逐步","zhúbù","từng bước"],
["大量","dàliàng","số lượng lớn"],
["比例","bǐlì","tỷ lệ"],
["比较","bǐjiào","so sánh, tương đối"],
["差距","chājù","khoảng cách chênh lệch"],
["差异","chāyì","sự khác biệt"],
["缺乏","quēfá","thiếu, thiếu hụt"],
["缺少","quēshǎo","thiếu"],
["充分","chōngfèn","đầy đủ"],
["充满","chōngmǎn","tràn đầy"],
["满足","mǎnzú","thỏa mãn, đáp ứng"],
["符合","fúhé","phù hợp"],
["适应","shìyìng","thích ứng"],
["适合","shìhé","phù hợp, thích hợp"],
["利用","lìyòng","lợi dụng, tận dụng"],
];
/* ============ DỮ LIỆU ĐOẠN VĂN (chọn từ điền chỗ trống) ============ */
const PASSAGES = [
{
template: "最近几年,越来越多的人开始关心环境问题。为了{0}大自然,政府{1}了不少新措施,例如{2}工厂排放污染物的数量。这些做法不仅有助于{3}绿色产业的发展,也让空气质量{4}好转。",
bank: ["保护","采取","限制","促进","逐渐"],
explain: [
"\"保护\" (bảo vệ) là động từ tự nhiên nhất đi với \"大自然\" (thiên nhiên).",
"\"采取\" luôn đi cùng \"措施\" thành cụm cố định \"采取措施\" (áp dụng biện pháp).",
"\"限制\" hợp với \"...的数量\" (hạn chế số lượng), đúng ngữ cảnh hạn chế lượng chất thải.",
"\"促进\" đi với \"...的发展\" thành cụm quen thuộc \"促进发展\" (thúc đẩy sự phát triển).",
"\"逐渐\" là phó từ đứng trước động từ chỉ sự thay đổi từ từ, rất hợp với \"好转\" (chuyển biến tốt lên)."
]
},
{
template: "小王决定重新开始学钢琴。虽然一开始他{0}信心,弹得也不好,但他每天都{1}练习,从不放弃。学习新技能常常需要{2},不能急于求成。慢慢地,他不再{3}那么大的压力,反而越来越享受弹琴的过程,心里也{4}了对音乐的热爱。",
bank: ["缺乏","坚持","耐心","承受","充满"],
explain: [
"\"缺乏\" hợp với \"信心\" — thiếu tự tin, đúng với việc \"弹得也不好\" ngay sau đó.",
"\"坚持\" hợp với \"练习\", nối liền với vế sau \"从不放弃\" — kiên trì luyện tập mỗi ngày.",
"\"耐心\" (kiên nhẫn) là điều học kỹ năng mới luôn cần, khớp với \"不能急于求成\".",
"\"承受\" luôn đi với \"压力\" thành cụm cố định \"承受压力\" (chịu áp lực).",
"\"充满\" đi với danh từ trừu tượng như \"热爱\" thành \"充满热爱\" (tràn đầy tình yêu)."
]
},
{
template: "在一个团队里,成员之间需要经常{0},才能及时了解彼此的想法。遇到不同意见时,要{1}对方的看法,不要急着反驳。开会的时候,每个人都应该大胆{2}自己的观点。项目完成以后,大家还可以互相{3}经验,一起进步。只有真正的{4},团队才能取得更好的成绩。",
bank: ["沟通","尊重","表达","分享","合作"],
explain: [
"\"沟通\" phù hợp nhất vì vế sau \"才能及时了解彼此的想法\" — giao tiếp mới trực tiếp giúp hiểu suy nghĩ của nhau.",
"\"尊重\" luôn đi với \"看法/意见\" của người khác.",
"\"表达\" đi với \"观点\" thành \"表达观点\", và \"大胆\" rất hợp với hành động dám nói lên ý kiến.",
"\"分享\" luôn đi với \"经验\" thành cụm cố định \"分享经验\" (chia sẻ kinh nghiệm).",
"\"合作\" ở đây là danh từ trong cụm \"真正的合作\" (sự hợp tác thực sự) — đúng chủ đề làm việc nhóm."
]
},
{
template: "良好的生活{0}对身体健康非常重要。每天保证{1}的睡眠时间,可以让人第二天更有精神。运动也要选择{2}自己身体状况的方式,不能勉强。如果长期压力太大、{3}不稳定,也会影响健康。养成新的生活方式并不容易,需要慢慢{4}下去,才能看到效果。",
bank: ["习惯","充分","适合","情绪","坚持"],
explain: [
"\"习惯\" là danh từ duy nhất hợp sau \"生活\" — \"生活习惯\" (thói quen sinh hoạt) là cụm cố định.",
"\"充分\" đi với \"的睡眠时间\" thành \"充分的睡眠\" (giấc ngủ đầy đủ).",
"\"适合\" đi với \"自己身体状况的方式\" — chọn cách phù hợp với tình trạng cơ thể của mình.",
"\"情绪\" hợp với \"不稳定\" thành \"情绪不稳定\" (cảm xúc bất ổn), liền mạch với vế trước \"压力太大\".",
"\"坚持\" đi với \"下去\" thành cụm cố định \"坚持下去\" (kiên trì đến cùng)."
]
}
];
let ACTIVE_WORDS = WORDS.slice();
/* ============ ĐỒNG BỘ TỪ GOOGLE SHEET ============ */
const SHEET_ID = '1dg-HXoyPymLZN7L3AC8HH2F1eXHLLStr';
const SHEET_CSV_URL = 'https://docs.google.com/spreadsheets/d/' + SHEET_ID + '/export?format=csv';
let sheetStatus = 'idle';
let sheetNewCount = 0;
function parseCsvLine(line){
const result = [];
let cur = '';
let inQuotes = false;
for(let i=0;i<line.length;i++){
const c = line[i];
if(inQuotes){
if(c === '"'){
if(line[i+1] === '"'){ cur += '"'; i++; }
else { inQuotes = false; }
} else { cur += c; }
} else {
if(c === '"'){ inQuotes = true; }
else if(c === ','){ result.push(cur); cur=''; }
else { cur += c; }
}
}
result.push(cur);
return result;
}
function parseSheetCsv(csvText){
const hanziRe = /[\u4e00-\u9fff]/;
const latinRe = /[a-zA-ZÀ-ỹ]/;
const lines = csvText.split(/\r\n|\n/);
const out = [];
for(const line of lines){
if(!line) continue;
const cols = parseCsvLine(line);
const hanziRaw = (cols[1]||'').trim();
const pinyinRaw = (cols[2]||'').trim();
const meaningRaw = (cols[3]||'').trim();
if(!hanziRe.test(hanziRaw)) continue;
if(!meaningRaw || !latinRe.test(meaningRaw)) continue;
const hanzi = hanziRaw.split(/[=\/]/)[0].trim();
if(!hanzi || !hanziRe.test(hanzi)) continue;
out.push([hanzi, pinyinRaw, meaningRaw]);
}
return out;
}
async function syncFromSheet(){
sheetStatus = 'loading';
renderSheetStatus();
try{
const resp = await fetch(SHEET_CSV_URL, {cache:'no-store'});
if(!resp.ok) throw new Error('http ' + resp.status);
const text = await resp.text();
const liveWords = parseSheetCsv(text);
const map = {};
WORDS.forEach(w=>{ map[w[0]] = w; });
let added = 0;
liveWords.forEach(w=>{
if(!map[w[0]]){ map[w[0]] = w; added++; }
});
ACTIVE_WORDS = Object.values(map);
sheetNewCount = added;
sheetStatus = 'ok';
}catch(e){
sheetStatus = 'error';
}
renderSheetStatus();
refreshQueueStatus();
}
function renderSheetStatus(){
const el = $('sheet-status');
if(!el) return;
if(sheetStatus === 'loading'){
el.textContent = 'Đang tải từ vựng từ Google Sheet...';
} else if(sheetStatus === 'ok'){
el.textContent = sheetNewCount > 0
? 'Đã thêm ' + sheetNewCount + ' từ mới từ Google Sheet · tổng ' + ACTIVE_WORDS.length + ' từ.'
: 'Đã đồng bộ với Google Sheet · tổng ' + ACTIVE_WORDS.length + ' từ.';
} else if(sheetStatus === 'error'){
el.textContent = 'Không tải được Google Sheet lúc này — đang dùng ' + ACTIVE_WORDS.length + ' từ có sẵn trong app.';
} else {
el.textContent = '';
}
}
/* ============ LƯU TRỮ VÒNG TỪ VỰNG KHÔNG LẶP LẠI ============ */
const QUEUE_KEY = 'hsk5_queue';
function hasStorage(){
return typeof window !== 'undefined' && window.storage && typeof window.storage.get === 'function';
}
async function loadQueue(){
if(!hasStorage()) return [];
try{
const res = await window.storage.get(QUEUE_KEY, false);
if(res && res.value){
const arr = JSON.parse(res.value);
if(Array.isArray(arr)) return arr;
}
}catch(e){}
return [];
}
async function saveQueue(queue){
if(!hasStorage()) return;
try{
await window.storage.set(QUEUE_KEY, JSON.stringify(queue), false);
}catch(e){}
}
async function clearQueue(){
if(!hasStorage()) return;
try{
await window.storage.delete(QUEUE_KEY, false);
}catch(e){}
}
async function refreshQueueStatus(){
const statusEl = $('queue-status');
if(!statusEl) return;
if(!hasStorage()){
statusEl.textContent = '';
return;
}
const queue = await loadQueue();
if(queue.length > 0){
statusEl.textContent = 'Còn ' + queue.length + ' từ chưa lặp lại trong vòng này.';
} else {
statusEl.textContent = 'Vòng từ vựng mới, sẵn sàng ' + ACTIVE_WORDS.length + ' từ.';
}
}
/* ============ TIỆN ÍCH CHUNG ============ */
function $(id){ return document.getElementById(id); }
function showScreen(id){
document.querySelectorAll('.screen').forEach(s=>s.classList.remove('active'));
$(id).classList.add('active');
}
function shuffle(arr){
const a = arr.slice();
for(let i=a.length-1;i>0;i--){
const j = Math.floor(Math.random()*(i+1));
[a[i],a[j]]=[a[j],a[i]];
}
return a;
}
let state = null;
let selectedPlayerCount = 2;
let selectedMode = 'vocab';
/* ============ LỰA CHỌN TRÊN MÀN HÌNH CHÍNH ============ */
document.querySelectorAll('#player-picker .pill').forEach(p=>{
p.addEventListener('click', ()=>{
document.querySelectorAll('#player-picker .pill').forEach(x=>x.classList.remove('selected'));
p.classList.add('selected');
selectedPlayerCount = parseInt(p.dataset.players,10);
updateStartScreenVisibility();
});
});
document.querySelectorAll('#mode-picker .pill').forEach(p=>{
p.addEventListener('click', ()=>{
document.querySelectorAll('#mode-picker .pill').forEach(x=>x.classList.remove('selected'));
p.classList.add('selected');
selectedMode = p.dataset.mode;
updateStartScreenVisibility();
});
});
document.querySelectorAll('#round-picker .pill').forEach(p=>{
p.addEventListener('click', ()=>{
document.querySelectorAll('#round-picker .pill').forEach(x=>x.classList.remove('selected'));
p.classList.add('selected');
});
});
document.querySelectorAll('#passage-picker .pill').forEach(p=>{
p.addEventListener('click', ()=>{
document.querySelectorAll('#passage-picker .pill').forEach(x=>x.classList.remove('selected'));
p.classList.add('selected');
});
});
function updateStartScreenVisibility(){
$('p2-field-wrap').style.display = selectedPlayerCount===1 ? 'none' : 'block';
$('names-two-col').classList.toggle('single', selectedPlayerCount===1);
$('round-picker-card').style.display = selectedMode==='vocab' ? 'block' : 'none';
$('passage-picker-card').style.display = selectedMode==='passage' ? 'block' : 'none';
$('vocab-status-block').style.display = selectedMode==='vocab' ? 'block' : 'none';
}
/* ============ BẮT ĐẦU VÁN CHƠI ============ */
$('btn-start').addEventListener('click', async ()=>{
const p1 = $('p1name').value.trim() || 'Người chơi 1';
const p2 = selectedPlayerCount===2 ? ($('p2name').value.trim() || 'Người chơi 2') : '';
if(selectedMode === 'vocab'){
await startVocabGame(p1,p2);
} else {
startPassageGame(p1,p2);
}
});
function setupGameScreenForMode(mode){
$('word-stage').style.display = mode==='vocab' ? 'flex' : 'none';
$('passage-stage').style.display = mode==='passage' ? 'block' : 'none';
$('timer-track').style.display = mode==='vocab' ? 'block' : 'none';
$('streak-note').style.display = mode==='vocab' ? 'block' : 'none';
$('name-p1-display').textContent = state.p1;
$('name-p2-display').textContent = state.p2 || '';
$('score-p1').textContent = '0';
$('score-p2').textContent = '0';
const solo = state.playerCount === 1;
$('tag-p2').style.display = solo ? 'none' : 'block';
document.querySelector('.scoreboard').classList.toggle('solo', solo);
}
function advanceTurn(){
if(state.playerCount === 2){ state.turn = 1 - state.turn; }
}
/* ============ CHẾ ĐỘ ĐẤU TỪ VỰNG ============ */
async function startVocabGame(p1,p2){
const roundsPerPlayer = parseInt(document.querySelector('#round-picker .pill.selected').dataset.rounds, 10);
const totalQuestions = roundsPerPlayer * selectedPlayerCount;
const wordMap = {};
ACTIVE_WORDS.forEach(w => { wordMap[w[0]] = w; });
let queue = await loadQueue();
queue = queue.filter(h => wordMap[h]);
while(queue.length < totalQuestions){
queue = queue.concat(shuffle(ACTIVE_WORDS.map(w=>w[0])));
}
const deckHanzi = queue.slice(0, totalQuestions);
await saveQueue(queue.slice(totalQuestions));
const deck = deckHanzi.map(h => wordMap[h]);
state = {
mode:'vocab',
playerCount: selectedPlayerCount,
p1, p2,
scores: [0,0],
streaks: [0,0],
turn: 0,
qIndex: 0,
total: totalQuestions,
deck,
timer: null,
timeLeft: 15,
hintTimer: null,
answered: false,
};
setupGameScreenForMode('vocab');
showScreen('screen-game');
loadVocabQuestion();
}
function loadVocabQuestion(){
clearInterval(state.timer);
clearTimeout(state.hintTimer);
state.answered = false;
state.timeLeft = 15;
const word = state.deck[state.qIndex];
const [hanzi, pinyin, meaning] = word;
const others = ACTIVE_WORDS.filter(w => w[0] !== hanzi);
const distractors = shuffle(others).slice(0,3).map(w=>w[2]);
const options = shuffle([meaning, ...distractors]);
$('word-hanzi').textContent = hanzi;
const pinyinEl = $('word-pinyin');
pinyinEl.textContent = pinyin;
pinyinEl.classList.remove('show');
$('turn-label').textContent = 'Lượt của ' + (state.turn===0 ? state.p1 : state.p2);
$('tag-p1').classList.toggle('active', state.turn===0);
$('tag-p2').classList.toggle('active', state.turn===1);
$('round-count').textContent = (state.qIndex+1) + ' / ' + state.total;
$('score-p1').textContent = state.scores[0];
$('score-p2').textContent = state.scores[1];
const seal = $('seal-stamp');
seal.className = 'seal-stamp';
seal.textContent = '';
$('streak-note').textContent = '';
$('next-row').innerHTML = '';
const answersDiv = $('answers');
answersDiv.innerHTML = '';
options.forEach(opt=>{
const btn = document.createElement('button');
btn.className = 'answer-btn';
btn.textContent = opt;
btn.addEventListener('click', ()=> handleVocabAnswer(btn, opt, meaning));
answersDiv.appendChild(btn);
});
$('timer-fill').style.width = '100%';
$('timer-fill').classList.remove('low');
state.hintTimer = setTimeout(()=>{ pinyinEl.classList.add('show'); }, 7000);
state.timer = setInterval(()=>{
state.timeLeft -= 0.1;
const pct = Math.max(0, (state.timeLeft/15)*100);
$('timer-fill').style.width = pct + '%';
if(state.timeLeft <= 4) $('timer-fill').classList.add('low');
if(state.timeLeft <= 0){
handleVocabAnswer(null, null, meaning);
}
}, 100);
}
function handleVocabAnswer(btnEl, chosen, correctMeaning){
if(state.answered) return;
state.answered = true;
clearInterval(state.timer);
clearTimeout(state.hintTimer);
$('word-pinyin').classList.add('show');
const isCorrect = chosen === correctMeaning;
const player = state.turn;
document.querySelectorAll('.answer-btn').forEach(b=>{
b.disabled = true;
if(b.textContent === correctMeaning){
b.classList.add('correct');
} else if(b === btnEl){
b.classList.add('wrong');
} else {
b.classList.add('faded');
}
});
const seal = $('seal-stamp');
if(isCorrect){
state.streaks[player]++;
const bonus = Math.min(state.streaks[player]*2, 10);
state.scores[player] += 10 + bonus;
seal.textContent = '对';
seal.classList.add('stamp-in');
if(state.streaks[player] >= 2){
$('streak-note').textContent = 'Chuỗi đúng x' + state.streaks[player] + ' — thưởng +' + bonus + ' điểm';
}
} else {
state.streaks[player] = 0;
seal.textContent = '错';
seal.classList.add('stamp-in','wrong');
}
$('score-p1').textContent = state.scores[0];
$('score-p2').textContent = state.scores[1];
const nextBtn = document.createElement('button');
nextBtn.className = 'btn-seal';
nextBtn.textContent = (state.qIndex + 1 >= state.total) ? 'Xem kết quả' : 'Câu tiếp theo';
nextBtn.addEventListener('click', advanceVocab);
$('next-row').innerHTML = '';
$('next-row').appendChild(nextBtn);
}
function advanceVocab(){
state.qIndex++;
advanceTurn();
if(state.qIndex >= state.total){
endGame();
} else {
loadVocabQuestion();
}
}
/* ============ CHẾ ĐỘ ĐỌC ĐOẠN VĂN ============ */
function startPassageGame(p1,p2){
const count = parseInt(document.querySelector('#passage-picker .pill.selected').dataset.count, 10);
const n = Math.min(count, PASSAGES.length);
const passageOrder = shuffle(PASSAGES.map((_,i)=>i)).slice(0,n);
const blankList = [];
passageOrder.forEach(pIdx=>{
for(let b=0;b<PASSAGES[pIdx].bank.length;b++){
blankList.push({passageIdx:pIdx, blankIdx:b});
}
});
const answeredMap = {};
passageOrder.forEach(pIdx=>{ answeredMap[pIdx] = new Array(PASSAGES[pIdx].bank.length).fill(null); });
state = {
mode:'passage',
playerCount: selectedPlayerCount,
p1, p2,
scores: [0,0],
turn: 0,
total: blankList.length,
blankList,
passageOrder,
globalIndex: 0,
answeredMap,
answered: false,
};
setupGameScreenForMode('passage');
showScreen('screen-game');
loadPassageBlank();
}
function renderPassageText(passage, answeredArr, currentBlankIdx){
let html = passage.template;
for(let i=0;i<passage.bank.length;i++){
let slotHtml;
const rec = answeredArr[i];
if(rec){
slotHtml = '<span class="blank-slot ' + (rec.correct?'correct':'wrong') + '">' + passage.bank[i] + '</span>';
} else if(i === currentBlankIdx){
slotHtml = '<span class="blank-slot current">___</span>';
} else {
slotHtml = '<span class="blank-slot">___</span>';
}
html = html.split('{'+i+'}').join(slotHtml);
}
$('passage-text').innerHTML = html;
}
function loadPassageBlank(){
state.answered = false;
const ref = state.blankList[state.globalIndex];
const passage = PASSAGES[ref.passageIdx];
const answeredArr = state.answeredMap[ref.passageIdx];
renderPassageText(passage, answeredArr, ref.blankIdx);
const posInOrder = state.passageOrder.indexOf(ref.passageIdx) + 1;
$('passage-progress').textContent = 'Đoạn ' + posInOrder + '/' + state.passageOrder.length + ' · Câu ' + (ref.blankIdx+1) + '/' + passage.bank.length;
$('round-count').textContent = (state.globalIndex+1) + ' / ' + state.total;
$('turn-label').textContent = 'Lượt của ' + (state.turn===0 ? state.p1 : state.p2);
$('score-p1').textContent = state.scores[0];
$('score-p2').textContent = state.scores[1];
$('tag-p1').classList.toggle('active', state.turn===0);
$('tag-p2').classList.toggle('active', state.turn===1);
$('explain-box').style.display = 'none';
$('explain-box').textContent = '';
const answersDiv = $('answers');
answersDiv.innerHTML = '';
const options = shuffle(passage.bank.slice());
const correctWord = passage.bank[ref.blankIdx];
options.forEach(opt=>{
const btn = document.createElement('button');
btn.className = 'answer-btn hanzi centered';
btn.textContent = opt;
btn.addEventListener('click', ()=> handlePassageAnswer(btn, opt, correctWord, ref, passage));
answersDiv.appendChild(btn);
});
$('next-row').innerHTML = '';
}
function handlePassageAnswer(btnEl, chosen, correctWord, ref, passage){
if(state.answered) return;
state.answered = true;
const isCorrect = chosen === correctWord;
state.answeredMap[ref.passageIdx][ref.blankIdx] = {chosen, correct:isCorrect};
document.querySelectorAll('.answer-btn').forEach(b=>{
b.disabled = true;
if(b.textContent === correctWord){
b.classList.add('correct');
} else if(b === btnEl){
b.classList.add('wrong');
} else {
b.classList.add('faded');
}
});
if(isCorrect){
state.scores[state.turn] += 10;
}
$('score-p1').textContent = state.scores[0];
$('score-p2').textContent = state.scores[1];
renderPassageText(passage, state.answeredMap[ref.passageIdx], -1);
const explainBtn = document.createElement('button');
explainBtn.className = 'explain-toggle';
explainBtn.textContent = 'Vì sao chọn "' + correctWord + '"?';
explainBtn.addEventListener('click', ()=>{
const box = $('explain-box');
box.style.display = 'block';
box.textContent = passage.explain[ref.blankIdx];
});
const nextBtn = document.createElement('button');
nextBtn.className = 'btn-seal';
nextBtn.textContent = (state.globalIndex + 1 >= state.total) ? 'Xem kết quả' : 'Chỗ trống tiếp theo';
nextBtn.addEventListener('click', advancePassage);
$('next-row').innerHTML = '';
$('next-row').appendChild(explainBtn);
$('next-row').appendChild(nextBtn);
}
function advancePassage(){
state.globalIndex++;
advanceTurn();
if(state.globalIndex >= state.total){
endGame();
} else {
loadPassageBlank();
}
}
/* ============ MÀN HÌNH KẾT QUẢ ============ */
function endGame(){
showScreen('screen-end');
const s1 = state.scores[0], s2 = state.scores[1];
$('final-name-p1').textContent = state.p1;
$('final-score-p1').textContent = s1;
$('final-p1').classList.remove('winner');
$('final-p2').classList.remove('winner');
if(state.playerCount === 1){
$('final-p2').style.display = 'none';
document.querySelector('.final-scores').classList.add('solo');
$('end-title').textContent = 'Hoàn thành!';
$('end-sub').textContent = state.p1 + ' đạt ' + s1 + ' điểm';
$('final-p1').classList.add('winner');
} else {
$('final-p2').style.display = '';
document.querySelector('.final-scores').classList.remove('solo');
$('final-name-p2').textContent = state.p2;
$('final-score-p2').textContent = s2;
if(s1 === s2){
$('end-title').textContent = 'Hòa nhau!';
$('end-sub').textContent = state.p1 + ' và ' + state.p2 + ' cùng đạt ' + s1 + ' điểm';
} else if(s1 > s2){
$('end-title').textContent = state.p1 + ' thắng!';
$('end-sub').textContent = 'Chênh lệch ' + (s1-s2) + ' điểm';
$('final-p1').classList.add('winner');
} else {
$('end-title').textContent = state.p2 + ' thắng!';
$('end-sub').textContent = 'Chênh lệch ' + (s2-s1) + ' điểm';
$('final-p2').classList.add('winner');
}
}
}
/* ============ NÚT ĐIỀU HƯỚNG ============ */
$('btn-again').addEventListener('click', ()=>{
showScreen('screen-start');
refreshQueueStatus();
});
$('btn-menu').addEventListener('click', ()=>{
showScreen('screen-start');
refreshQueueStatus();
});
$('btn-reset-queue').addEventListener('click', async ()=>{
await clearQueue();
refreshQueueStatus();
});
$('btn-sync-sheet').addEventListener('click', ()=>{
syncFromSheet();
});
/* ============ KHỞI TẠO ============ */
updateStartScreenVisibility();
syncFromSheet();
</script>
</body>
</html>
Game Source: 對戰 · Song Đấu HSK5
Creator: PhantomWolf63
Libraries: none
Complexity: complex (1176 lines, 43.2 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: song-u-hsk5-phantomwolf63" to link back to the original. Then publish at arcadelab.ai/publish.