Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@
|---|---|---|---|---|
| 書籤 | `hakkaBookmarks` | `bookmarks` | ✅ | 逐項單調(同表取較新) |
| 偏好 | `romanizerJoiningMode` | `preferences` | ✅ | LWW |
| 遊戲上次腔調/級別 | `hakkaGameLastDataVarName` | `preferences` | ✅ | LWW(空字串視為未設定,不覆蓋本地) |
| 學習進度 | `hakkaLearningProgress` | `learning_progress` | ✅ | 逐項單調不回退 |
| 每日統計 | `hakkaDailyStats` | `daily_stats` | ✅ | 逐項相加(delta 基準) |
| 字典快取 | (`HakkaDataDB`) | — | ❌ | 不同步(可重建) |
Expand Down
7 changes: 4 additions & 3 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<link rel="icon" type="image/png" sizes="192x192" href="android-chrome-192x192.png">
<link rel="icon" type="image/png" sizes="512x512" href="android-chrome-512x512.png">
<link rel="shortcut icon" href="favicon.ico">
<link rel="stylesheet" href="style.css?v=4.4.1" />
<link rel="stylesheet" href="style.css?v=4.4.3" />
<link href="https://tauhu.tw/tauhu-oo.css" rel="stylesheet" />

<!-- 先載入外部 SDK -->
Expand All @@ -33,8 +33,8 @@
<script type="text/javascript" src="js/game/phonology-rules.js?v=4.3.2" defer></script>
<script type="text/javascript" src="js/game/question-gen.js?v=4.6.2" defer></script>
<script type="text/javascript" src="js/game/srs.js?v=4.3.2" defer></script>
<script type="text/javascript" src="js/game/game-ui.js?v=4.6.5" defer></script>
<script type="text/javascript" src="js/cloud-sync.js?v=4.3.2" defer></script>
<script type="text/javascript" src="js/game/game-ui.js?v=4.6.8" defer></script>
<script type="text/javascript" src="js/cloud-sync.js?v=4.3.4" defer></script>

<!-- Driver.js for Onboarding -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/driver.js@1.0.1/dist/driver.css"/>
Expand Down Expand Up @@ -181,6 +181,7 @@ <h2 id="gameModalTitle" style="margin-bottom: 0;">客源翠學習遊戲</h2>
<div id="game-setup-ready-block">
<p>準備好開始學習 <span id="game-target-level" style="font-weight: bold; color: var(--primary-color);"></span> 了嗎?
<button id="gameChangeLevelBtn" class="game-btn" style="margin-left: 10px; font-size: 0.9em; padding: 4px 8px; width: auto;">換其他腔/其他級</button>
<button id="gameUseLastPlayedBtn" class="game-btn game-btn-highlight" style="display: none; margin-left: 10px; font-size: 0.9em; padding: 4px 8px; width: auto;" title="代入上一次實際遊戲个腔調摎級別">搞上擺个腔級</button>
</p>
<p>每局約 10 題(新詞會多幾題加強)選擇題。</p>
<div style="margin-bottom: 15px; text-align: left; display: inline-block;">
Expand Down
55 changes: 46 additions & 9 deletions js/cloud-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ async function syncFromCloud() {
const localPrefs = {
romanizerJoiningMode:
localStorage.getItem('romanizerJoiningMode') || 'none',
gameLastDataVarName:
localStorage.getItem('hakkaGameLastDataVarName') || '',
};
const cloudPrefs = data.preferences || {};

Expand All @@ -211,6 +213,12 @@ async function syncFromCloud() {
const syncedStats = JSON.parse(
localStorage.getItem('hakkaDailyStatsSynced') || '{}'
);
// 偏好設定的已同步快照:本地值 === 快照,才代表本地沒有「尚未推上雲端」的新變更,
// 此時才可以放心讓雲端值覆蓋本地;否則就是本地剛改過還沒同步,必須保留本地、留給下面 Smart Push 推上去。
// 沒有這層判斷的話,本地剛寫入的新值會被下面「雲端有值就覆寫本地」的邏輯讀成舊資料而遺失。
const syncedPrefs = JSON.parse(
localStorage.getItem('hakkaPrefsSynced') || '{}'
);

// 2. 合併資料
const mergedBookmarks = mergeBookmarks(localBookmarks, cloudBookmarks);
Expand All @@ -230,21 +238,34 @@ async function syncFromCloud() {
// 註:已同步基準快照 hakkaDailyStatsSynced 於「上傳成功後」才更新(見下方),
// 避免 push 失敗卻把基準推進,導致該次新增量算成 0、永遠傳不上去。

// 合併偏好設定(雲端有值時優先使用,否則保留本地值供後續上傳)
// 合併偏好設定:本地相對快照沒變 → 套用雲端值;本地相對快照有變 → 保留本地、留給 Smart Push 推上去
// 使用 !== undefined && !== null 確保 falsy 值(如空字串)也能正確處理
if (cloudPrefs.romanizerJoiningMode !== undefined && cloudPrefs.romanizerJoiningMode !== null) {
localStorage.setItem('romanizerJoiningMode', cloudPrefs.romanizerJoiningMode);
let finalRomanizerJoiningMode = localPrefs.romanizerJoiningMode;
if (localPrefs.romanizerJoiningMode === (syncedPrefs.romanizerJoiningMode || 'none')) {
if (cloudPrefs.romanizerJoiningMode !== undefined && cloudPrefs.romanizerJoiningMode !== null) {
finalRomanizerJoiningMode = cloudPrefs.romanizerJoiningMode;
}
}
localStorage.setItem('romanizerJoiningMode', finalRomanizerJoiningMode);

// 空字串代表雲端「從未玩過遊戲」,此時不套用(保留本地或快照值)
let finalGameLastDataVarName = localPrefs.gameLastDataVarName;
if (localPrefs.gameLastDataVarName === (syncedPrefs.gameLastDataVarName || '')) {
if (cloudPrefs.gameLastDataVarName) {
finalGameLastDataVarName = cloudPrefs.gameLastDataVarName;
}
}
localStorage.setItem('hakkaGameLastDataVarName', finalGameLastDataVarName);

// 4. 智慧上傳 (Smart Push):只有結果與雲端不一致時才上傳
// 比對 merged vs cloud
const bookmarksChanged =
JSON.stringify(mergedBookmarks) !== JSON.stringify(cloudBookmarks);
// 簡單比對 preferences (目前只有一個欄位)
// [修正 Round 3] 移除 cloudPrefs.romanizerJoiningMode && 檢查,避免雲端為空時無法上傳本地變更
// 偏好設定比對用「合併後的最終值」而非同步前的 localPrefs,
// 否則本地被雲端值覆寫後,仍拿舊的 localPrefs 去比對會誤判成「有變更」,觸發把剛拉下來的舊值原封不動又推回雲端。
const prefsChanged =
localPrefs.romanizerJoiningMode !==
(cloudPrefs.romanizerJoiningMode || 'none');
finalRomanizerJoiningMode !== (cloudPrefs.romanizerJoiningMode || 'none') ||
finalGameLastDataVarName !== (cloudPrefs.gameLastDataVarName || '');
const progressChanged =
JSON.stringify(mergedProgress) !== JSON.stringify(cloudProgress);
const statsChanged =
Expand All @@ -256,18 +277,32 @@ async function syncFromCloud() {
} else {
console.log('[CloudSync] 資料一致,跳過上傳');
}
// 走到這裡=雲端已與 mergedStats 對齊(有變上傳成功、無變本就一致;
// 走到這裡=雲端已與 merged 結果對齊(有變上傳成功、無變本就一致;
// syncToCloud 失敗會 throw 到外層 catch、不會到這行)→ 安全推進基準快照。
localStorage.setItem('hakkaDailyStatsSynced', JSON.stringify(mergedStats));
localStorage.setItem(
'hakkaPrefsSynced',
JSON.stringify({
romanizerJoiningMode: finalRomanizerJoiningMode,
gameLastDataVarName: finalGameLastDataVarName,
})
);
} else {
// 雲端沒有資料,上傳本地資料
console.log('[CloudSync] 雲端無資料,執行初始化上傳');
await syncToCloud();
// 初始上傳成功後,基準=目前本地統計
// 初始上傳成功後,基準=目前本地統計/偏好設定
localStorage.setItem(
'hakkaDailyStatsSynced',
localStorage.getItem('hakkaDailyStats') || '{}'
);
localStorage.setItem(
'hakkaPrefsSynced',
JSON.stringify({
romanizerJoiningMode: localStorage.getItem('romanizerJoiningMode') || 'none',
gameLastDataVarName: localStorage.getItem('hakkaGameLastDataVarName') || '',
})
);
}

// 同步完成後立即更新 UI
Expand Down Expand Up @@ -301,6 +336,8 @@ async function syncToCloud() {
const preferences = {
romanizerJoiningMode:
localStorage.getItem('romanizerJoiningMode') || 'none',
gameLastDataVarName:
localStorage.getItem('hakkaGameLastDataVarName') || '',
};
const learningProgress = JSON.parse(
localStorage.getItem('hakkaLearningProgress') || '{}'
Expand Down
76 changes: 72 additions & 4 deletions js/game/game-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,44 @@ let gameActiveDataVarName = '';
let currentGameAudioElements = [];
let currentQuestionAudioPromise = Promise.resolve();

// 記住「上一次玩遊戲」用的腔調+級別(dataVarName,如 '四基')。
// 開遊戲 modal 仍照舊優先用網頁本身(或其他功能)目前設定的腔調/級別;
// 「搞上擺个腔級」按鈕按下去才會代入這裡記住的上次遊戲腔級。
const GAME_LAST_VAR_NAME_KEY = 'hakkaGameLastDataVarName';

function getLastPlayedGameVarName() {
try {
return localStorage.getItem(GAME_LAST_VAR_NAME_KEY) || null;
} catch (e) {
console.warn('Failed to read last played game level', e);
return null;
}
}

// 遊戲實際開打時才記錄,藉此代表「上一次玩」而非「選過但沒玩」
function saveLastPlayedGameVarName(dataVarName) {
if (!dataVarName) return;
try {
if (localStorage.getItem(GAME_LAST_VAR_NAME_KEY) === dataVarName) return;
localStorage.setItem(GAME_LAST_VAR_NAME_KEY, dataVarName);
if (typeof window.triggerCloudSync === 'function') {
window.triggerCloudSync();
}
} catch (e) {
console.warn('Failed to save last played game level', e);
}
}

// 只在「有上次玩過的紀錄、且跟目前 ready block 顯示的腔級不同」時才秀出按鈕,
// 避免使用者按了卻沒變化、一頭霧水。
function refreshUseLastPlayedBtn() {
const btn = document.getElementById('gameUseLastPlayedBtn');
if (!btn) return;
const lastVarName = getLastPlayedGameVarName();
const hasDifferentLast = !!lastVarName && !!window[lastVarName] && lastVarName !== gameActiveDataVarName;
btn.style.display = hasDifferentLast ? 'inline-block' : 'none';
}

function initGameUI() {
const startGameBtn = document.getElementById('startGameBtn');
const headerStartGameBtn = document.getElementById('headerStartGameBtn');
Expand Down Expand Up @@ -37,6 +75,7 @@ function initGameUI() {
if (selectBlock) selectBlock.style.display = 'none';
const startSessionBtn = document.getElementById('gameStartSessionBtn');
if (startSessionBtn) startSessionBtn.style.display = 'block';
refreshUseLastPlayedBtn();
showGameView('setup');
gameModal.style.display = 'flex';
return;
Expand Down Expand Up @@ -75,6 +114,7 @@ function initGameUI() {
if (selectBlock) selectBlock.style.display = 'none';
const startSessionBtn = document.getElementById('gameStartSessionBtn');
if (startSessionBtn) startSessionBtn.style.display = 'block';
refreshUseLastPlayedBtn();
showGameView('setup');
gameModal.style.display = 'flex';
return;
Expand Down Expand Up @@ -118,6 +158,7 @@ function initGameUI() {
document.getElementById('game-setup-select-block').style.display = 'none';
document.getElementById('game-setup-ready-block').style.display = 'block';
document.getElementById('gameStartSessionBtn').style.display = 'block';
refreshUseLastPlayedBtn();
} else {
alert('無此腔調/級別組合的資料!');
}
Expand All @@ -127,6 +168,28 @@ function initGameUI() {
});
}

const gameUseLastPlayedBtn = document.getElementById('gameUseLastPlayedBtn');
if (gameUseLastPlayedBtn) {
gameUseLastPlayedBtn.addEventListener('click', () => {
const lastVarName = getLastPlayedGameVarName();
if (!lastVarName) return;
const varData = window[lastVarName];
if (!varData) {
alert('揣無上擺个腔調/級別資料!');
return;
}
gameActiveDataVarName = varData.name;
const 腔 = lastVarName.substring(0, 1);
const 級 = lastVarName.substring(1);
gameActiveDialect = getDialectInfo(腔, 級).腔名 || '四縣';
document.getElementById('game-target-level').textContent = getFullLevelName(varData.name);
refreshUseLastPlayedBtn();
if (typeof trackEvent === 'function') {
trackEvent('use_last_played_level', 'Game', gameActiveDataVarName);
}
});
}

if (gameCloseBtn) {
gameCloseBtn.addEventListener('click', () => {
gameModal.style.display = 'none';
Expand All @@ -150,9 +213,12 @@ function initGameUI() {
const gameSetupReturnBtn = document.getElementById('gameSetupReturnBtn');
if (gameSetupReturnBtn) {
gameSetupReturnBtn.addEventListener('click', () => {
document.getElementById('game-setup-ready-block').style.display = 'none';
document.getElementById('gameStartSessionBtn').style.display = 'none';
document.getElementById('game-setup-select-block').style.display = 'block';
// 回選項畫面(沿用剛打完那局的腔調/級別),使用者不一定要換腔換級,
// 想換的話畫面上本來就有「換其他腔/其他級」按鈕可以點,不必每次都先逼著重選。
document.getElementById('game-setup-select-block').style.display = 'none';
document.getElementById('game-setup-ready-block').style.display = 'block';
document.getElementById('gameStartSessionBtn').style.display = 'block';
refreshUseLastPlayedBtn();
showGameView('setup');
});
}
Expand Down Expand Up @@ -248,7 +314,9 @@ async function startSession() {
currentSession = await generateGameSession(gameActiveDialect, gameActiveDataVarName, { orderMode, mixMode, types });
currentQuestionIndex = 0;
score = 0;


saveLastPlayedGameVarName(gameActiveDataVarName);

if (typeof trackEvent === 'function') {
trackEvent('start_session', 'Game', `${gameActiveDataVarName}_${orderMode}_${mixMode}`);
}
Expand Down
14 changes: 14 additions & 0 deletions style.css
Original file line number Diff line number Diff line change
Expand Up @@ -3997,6 +3997,13 @@ mark {
.game-btn:hover {
background-color: #357ABD;
}
.game-btn-highlight {
background-color: #ff9800;
color: #1a1a1a;
}
.game-btn-highlight:hover {
background-color: #e68900;
}
.game-progress-bar {
display: flex;
justify-content: space-between;
Expand Down Expand Up @@ -4156,6 +4163,13 @@ mark {
.game-btn:hover {
background-color: #285e92;
}
.game-btn-highlight {
background-color: #ffb84d;
color: #1a1a1a;
}
.game-btn-highlight:hover {
background-color: #ffa726;
}
.game-question-card h1 {
color: #eee;
}
Expand Down
Loading