Truku-AI-Game / index.html
Lowking's picture
Upload 58 files
cf8255a verified
Raw
History Blame Contribute Delete
19.3 kB
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<title>太魯閣語數位學習 - 本機旗艦版 (ASR)</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
:root { --main: #2980b9; --accent: #e67e22; --danger: #e74c3c; --success: #27ae60; }
body { font-family: 'PingFang TC', 'Microsoft JhengHei', sans-serif; background: #fff8f0; margin: 0; padding-top: 220px; }
/* 頂部固定列 (紅色邊框代表本機開發版) */
.header-fixed { position: fixed; top: 0; left: 0; right: 0; background: white; padding: 15px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); z-index: 1000; text-align: center; border-bottom: 5px solid var(--danger); }
.mode-switch { margin-bottom: 10px; }
.btn-mode { padding: 10px 20px; border-radius: 30px; border: 2px solid var(--main); background: white; color: var(--main); font-weight: bold; cursor: pointer; margin: 0 5px; transition:0.2s; }
.btn-mode.active { background: var(--main); color: white; }
.search-box { width: 90%; max-width: 300px; padding: 8px 15px; border-radius: 20px; border: 1px solid #ccc; margin-bottom: 5px; outline:none; }
.filter-container { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-bottom: 10px; }
.filter-btn { padding: 5px 12px; border-radius: 15px; border: 1px solid #ddd; background: white; cursor: pointer; font-size: 0.9em; }
.filter-btn.active { background: var(--main); color: white; }
.timer-container { width: 90%; max-width: 600px; height: 10px; background: #eee; border-radius: 5px; margin: 10px auto; overflow: hidden; display: none; }
.timer-bar { height: 100%; background: var(--danger); width: 100%; transition: linear; }
.container { max-width: 1200px; margin: 0 auto; padding: 0 20px; }
.word-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; padding-bottom: 50px; }
.word-card { background: white; border-radius: 15px; padding: 20px; display: flex; justify-content: space-between; align-items: center; cursor: pointer; box-shadow: 0 4px 6px rgba(0,0,0,0.1); transition:0.2s; }
.word-card:hover { transform: translateY(-3px); box-shadow: 0 8px 15px rgba(0,0,0,0.15); }
.card-info .word { font-size: 1.5em; font-weight: bold; color: #2c3e50; }
.card-info .zh { color: var(--accent); }
#quiz-area { display: none; background: white; padding: 30px; border-radius: 25px; text-align: center; box-shadow: 0 10px 40px rgba(0,0,0,0.1); margin-top: 20px; }
.quiz-nav { display: flex; justify-content: center; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; }
/* 按鈕樣式 (全名版) */
.btn-type { padding: 8px 12px; border: 1px solid #aaa; border-radius: 10px; background: white; cursor: pointer; font-weight:bold; font-size: 0.95em; margin-bottom: 5px; }
.btn-type.active { background: var(--main); color: white; border-color: var(--main); }
.img-card { width: 220px; height: 220px; object-fit: cover; border-radius: 15px; border: 5px solid #eee; margin-bottom: 15px; }
.big-word { font-size: 2.5em; font-weight: bold; margin-bottom: 15px; color: #333; }
/* 錄音按鈕 (本機版專用) */
#record-area { margin-top: 25px; display: none; }
#btnRecord {
font-size: 1.4em; padding: 15px 40px; border-radius: 50px; border: none;
background: var(--danger); color: white; cursor: pointer; box-shadow: 0 4px 15px rgba(231, 76, 60, 0.4);
transition: 0.2s; display: inline-flex; align-items: center; gap: 10px;
}
#btnRecord:active { transform: scale(0.95); background: #c0392b; }
#btnRecord.recording { animation: pulse 1.5s infinite; background: #c0392b; }
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.7); } 70% { box-shadow: 0 0 0 15px rgba(231, 76, 60, 0); } 100% { box-shadow: 0 0 0 0 rgba(231, 76, 60, 0); } }
.quiz-input { font-size: 1.5em; padding: 10px; width: 80%; max-width: 400px; text-align: center; border: 3px solid var(--main); border-radius: 15px; }
.opt-grid { display: grid; gap: 12px; margin-top: 20px; width: 100%; max-width: 600px; margin-left: auto; margin-right: auto; }
.opt-btn { padding: 15px; font-size: 1.2em; border: 2px solid var(--main); background: white; border-radius: 12px; cursor: pointer; transition: 0.2s; }
.opt-btn:hover { background: #f0f7ff; transform: translateY(-2px); }
.feedback { font-size: 1.6em; font-weight: bold; margin: 20px 0; min-height: 1.6em; }
.next-btn { padding: 12px 40px; background: var(--success); color: white; border: none; border-radius: 30px; font-size: 1.2em; cursor: pointer; display: none; box-shadow: 0 4px 10px rgba(39, 174, 96, 0.4); }
#audioPlayer { display: none; }
</style>
</head>
<body>
<div class="header-fixed">
<div style="font-size: 0.8em; color: var(--danger); font-weight: bold;">🔴 V15 本機旗艦版 (ASR + 錄音啟用)</div>
<div class="mode-switch">
<button id="mode-dict" class="btn-mode active" onclick="setMode('dict')">📚 詞彙辭典</button>
<button id="mode-quiz" class="btn-mode" onclick="setMode('quiz')">🎮 競賽模式</button>
</div>
<div id="dict-tools"><input type="text" id="searchInput" class="search-box" placeholder="搜尋..." oninput="renderDict()"></div>
<div id="quiz-tools" style="display:none;">
<div class="quiz-nav">
<button onclick="switchType(1)" class="btn-type active">題型一:看圖說族語</button>
<button onclick="switchType(2)" class="btn-type">題型二:看中文寫族語</button>
<button onclick="switchType(3)" class="btn-type">題型三:聽中文說族語</button>
<button onclick="switchType(4)" class="btn-type">題型四:聽族語選回答</button>
</div>
<div class="timer-container" id="timerCont"><div id="tBar" class="timer-bar"></div></div>
</div>
</div>
<div class="container">
<div id="filterBtns" class="filter-container"></div>
<div id="wordList" class="word-list"></div>
<div id="quiz-area">
<div id="instr" style="color:#777; font-weight:bold; margin-bottom:15px; font-size:1.1em;">準備作答...</div>
<div id="display-content"></div>
<div id="record-area">
<button id="btnRecord" onmousedown="startRecording()" onmouseup="stopRecording()" ontouchstart="startRecording()" ontouchend="stopRecording()">🎙️ 按住說話</button>
<div id="score-display" style="margin-top:15px; font-size:1.3em; font-weight:bold; min-height: 1.5em; color: var(--main);"></div>
</div>
<div id="feedback" class="feedback"></div>
<button id="nextBtn" class="next-btn" onclick="nextQuestion()">下一題 ➔</button>
</div>
</div>
<audio id="audioPlayer"></audio>
<script>
let allData = [], currentFilter = '全部', currentMode = 'dict', quizType = 1, currentQ = null;
let usedIds = { 1: [], 2: [], 3: [], 4: [] };
let mediaRecorder, audioChunks = [];
let timerObj;
const TRUKU_DIR = "truku_final_audio/";
const ZH_DIR = "chinese_audio/";
// 音效 (本地優先)
const soundCorrect = new Audio("correct.mp3");
const soundWrong = new Audio("wrong.mp3");
const backupCorrect = "https://actions.google.com/sounds/v1/cartoon/pop.ogg";
const backupWrong = "https://www.soundjay.com/buttons/sounds/button-10.mp3";
soundCorrect.onerror = () => { soundCorrect.src = backupCorrect; };
soundWrong.onerror = () => { soundWrong.src = backupWrong; };
function playSound(isCorrect) {
let audio = isCorrect ? soundCorrect : soundWrong;
audio.currentTime = 0; audio.volume = 0.5;
audio.play().catch(e => console.log("音效需互動"));
}
async function init() {
try {
const res = await fetch('index.json?t=' + Date.now());
allData = await res.json();
renderCategories();
renderDict();
} catch(e) { alert("讀取資料失敗,請確認 index.json 存在"); }
}
// ✨✨✨ 錄音核心 (連接 server.py) ✨✨✨
async function startRecording() {
clearTimeout(timerObj);
document.getElementById('tBar').style.transition = 'none';
const btn = document.getElementById('btnRecord');
btn.classList.add('recording'); btn.innerText = "🔴 錄音中...";
document.getElementById('score-display').innerText = "";
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream);
audioChunks = [];
mediaRecorder.ondataavailable = e => audioChunks.push(e.data);
mediaRecorder.start();
} catch(err) {
alert("無法存取麥克風!");
btn.classList.remove('recording'); btn.innerText = "🎙️ 按住說話";
}
}
async function stopRecording() {
const btn = document.getElementById('btnRecord');
btn.classList.remove('recording'); btn.innerText = "🎙️ 按住說話";
if(!mediaRecorder || mediaRecorder.state === "inactive") return;
mediaRecorder.stop();
mediaRecorder.onstop = async () => {
const display = document.getElementById('score-display');
display.innerHTML = "⏳ AI 老師正在聽...";
const blob = new Blob(audioChunks, { type: 'audio/webm' });
const fd = new FormData();
fd.append("file", blob); fd.append("target_word", currentQ.word);
try {
// 呼叫 server.py
const res = await fetch("/api/score", { method: "POST", body: fd });
const r = await res.json();
if(r.status === "success") {
let color = r.score >= 80 ? "green" : (r.score >= 60 ? "orange" : "red");
display.innerHTML = `<span style="color:${color}; font-size:1.5em;">${r.score}分</span> <span style="font-size:0.8em; color:#555;">${r.comment}</span>`;
if(r.score >= 60) showResult("✅ 正確!", "green", true);
else showResult("❌ 請再試一次", "red", false);
} else {
display.innerText = "❌ " + r.comment; playSound(false);
}
} catch(e) { display.innerText = "❌ 連線錯誤"; playSound(false); }
};
}
function setMode(mode) {
currentMode = mode;
document.getElementById('mode-dict').classList.toggle('active', mode==='dict');
document.getElementById('mode-quiz').classList.toggle('active', mode==='quiz');
document.getElementById('wordList').style.display = mode==='dict'?'grid':'none';
document.getElementById('dict-tools').style.display = mode==='dict'?'block':'none';
document.getElementById('filterBtns').style.display = mode==='dict'?'flex':'none'; // 競賽時隱藏
document.getElementById('quiz-area').style.display = mode==='quiz'?'block':'none';
document.getElementById('quiz-tools').style.display = mode==='quiz'?'block':'none';
document.getElementById('timerCont').style.display = mode==='quiz'?'block':'none';
if(mode==='quiz') switchType(1); else renderDict();
}
function switchType(t) {
quizType = t;
document.querySelectorAll('.btn-type').forEach((b, i) => b.classList.toggle('active', (i+1)===t));
document.getElementById('instr').innerText = "準備好了嗎?";
document.getElementById('display-content').innerHTML = `<button onclick="nextQuestion()" class="btn-mode active" style="margin-top:20px;">開始挑戰</button>`;
document.getElementById('record-area').style.display = 'none';
document.getElementById('feedback').innerText = "";
document.getElementById('nextBtn').style.display = 'none';
document.getElementById('score-display').innerText = "";
clearTimeout(timerObj);
document.getElementById('tBar').style.width = '100%';
}
function startTimer(seconds) {
const bar = document.getElementById('tBar');
bar.style.transition = 'none'; bar.style.width = '100%';
setTimeout(() => {
bar.style.transition = `width ${seconds}s linear`;
bar.style.width = '0%';
}, 50);
clearTimeout(timerObj);
timerObj = setTimeout(() => {
let ans = currentQ.answer || currentQ.word;
showResult(`⏰ 時間到!答案是:${ans}`, "#7f8c8d", false);
document.getElementById('record-area').style.display = 'none';
}, seconds * 1000);
}
function nextQuestion() {
document.getElementById('feedback').innerText = "";
document.getElementById('nextBtn').style.display = 'none';
document.getElementById('score-display').innerText = "";
const targetType = (quizType <= 2) ? 'word' : 'sentence';
let pool = allData.filter(i => {
const matchCat = (currentFilter === '全部' || i.category === currentFilter);
const matchType = (i.type && i.type.toLowerCase() === targetType);
return matchCat && matchType;
});
let avail = pool.filter(i => !usedIds[quizType].includes(i.id));
if (avail.length === 0) {
if(pool.length===0) { alert(`此分類下沒有符合的題目!`); return; }
alert("題目做完了,重新洗牌!"); usedIds[quizType] = []; avail = pool;
}
currentQ = avail[Math.floor(Math.random()*avail.length)];
usedIds[quizType].push(currentQ.id);
renderQuestionInterface();
}
function renderQuestionInterface() {
const area = document.getElementById('display-content');
const recordArea = document.getElementById('record-area');
area.innerHTML = '';
let timeLimit = 10;
if(quizType === 1) {
// 題型一:錄音
document.getElementById('instr').innerText = "【題型一】看圖卡,按住說出族語:";
let fid = String(currentQ.id).padStart(4, '0');
area.innerHTML = `<img src="images/${fid}.jpg" class="img-card" onerror="this.src='https://via.placeholder.com/220?text=NoImage'">`;
recordArea.style.display = 'block';
timeLimit = 3;
} else if(quizType === 2) {
// 題型二:拼寫
document.getElementById('instr').innerText = "【題型二】看中文,拼出族語:";
area.innerHTML = `<div class="big-word">${currentQ.chinese}</div><input type="text" id="spellBox" class="quiz-input" placeholder="輸入..." onkeypress="if(event.key==='Enter') checkSpell()">`;
recordArea.style.display = 'none';
timeLimit = 5;
} else if(quizType === 3) {
// 題型三:錄音
document.getElementById('instr').innerText = "【題型三】聽中文,按住說出族語:";
area.innerHTML = `<div class="big-word">${currentQ.chinese}</div>`;
playAudio(ZH_DIR + currentQ.zh_audio);
recordArea.style.display = 'block';
timeLimit = 6;
} else if(quizType === 4) {
// 題型四:選擇
document.getElementById('instr').innerText = "【題型四】聽族語,選出正確回答:";
playAudio(TRUKU_DIR + currentQ.audio);
recordArea.style.display = 'none';
timeLimit = 6;
let sentencePool = allData.filter(i => i.type && i.type.toLowerCase() === 'sentence' && i.id !== currentQ.id);
if (sentencePool.length < 2) sentencePool = allData.filter(i => i.id !== currentQ.id);
let wrong = sentencePool.sort(()=>0.5-Math.random()).slice(0,2);
let opts = [...wrong, currentQ].sort(()=>0.5-Math.random());
let html = '<div class="opt-grid">';
opts.forEach(o => {
let label = o.answer || o.word;
html += `<button class="opt-btn" onclick="checkOpt('${o.id}', \`${label}\`)">${label}</button>`;
});
html += '</div>';
area.innerHTML = html;
}
startTimer(timeLimit);
}
function renderCategories() {
const cats = ['全部', ...new Set(allData.map(i => i.category))];
const c = document.getElementById('filterBtns'); c.innerHTML = '';
cats.forEach(x => {
const b = document.createElement('button'); b.className = `filter-btn ${x==='全部'?'active':''}`; b.innerText = x;
b.onclick=()=>{currentFilter=x; document.querySelectorAll('.filter-btn').forEach(z=>z.classList.remove('active')); b.classList.add('active'); if(currentMode==='dict') renderDict();};
c.appendChild(b);
});
}
function renderDict() {
const s = document.getElementById('searchInput').value.toLowerCase();
const l = document.getElementById('wordList'); l.innerHTML = '';
allData.filter(i=>(i.word.toLowerCase().includes(s)||i.chinese.includes(s))&&(currentFilter==='全部'||i.category===currentFilter)).forEach(i=>{
const d=document.createElement('div'); d.className='word-card';
d.onclick=()=>{playAudio(TRUKU_DIR+i.audio)};
d.innerHTML=`<div class="card-info"><span style="font-size:0.8em; color:#2980b9; background:#ebf5fb; padding:2px 8px; border-radius:10px;">${i.category}</span><div class="word">${i.word}</div><div class="zh">${i.chinese}</div></div><div style="font-size:1.5em">🔊</div>`;
l.appendChild(d);
});
}
function playAudio(src) { const p=document.getElementById('audioPlayer'); p.src=src; p.play(); }
function checkSpell() {
clearTimeout(timerObj);
const v = document.getElementById('spellBox').value.trim().toLowerCase();
let isRight = v===currentQ.word.toLowerCase();
showResult(isRight?"✅ 正確!":"❌ 錯誤", isRight?"green":"red", isRight);
}
function checkOpt(id, ansText) {
clearTimeout(timerObj);
let isRight = (id == currentQ.id);
let correctLabel = currentQ.answer || currentQ.word;
showResult(isRight?"✅ 正確!":"❌ 錯誤,答案是: "+correctLabel, isRight?"green":"red", isRight);
}
function showResult(t, c, isCorrect) {
const f = document.getElementById('feedback');
f.innerText = t; f.style.color = c;
document.getElementById('nextBtn').style.display='inline-block';
playSound(isCorrect);
}
init();
</script>
</body>
</html>