Skip to content

遊戲出題卡頓時強制先畫出「出題中」遮罩,避免誤觸第一題 - #230

Merged
Aiuanyu merged 1 commit into
mainfrom
claude/game-mode-mobile-lag-3bwp6s
Jul 15, 2026
Merged

遊戲出題卡頓時強制先畫出「出題中」遮罩,避免誤觸第一題#230
Aiuanyu merged 1 commit into
mainfrom
claude/game-mode-mobile-lag-3bwp6s

Conversation

@GJRobert

Copy link
Copy Markdown
Collaborator

按「開始挑戰」後,出題運算在慢速裝置上是同步且吃重的,會卡住主執行緒數秒。
過去雖然已經有 loading-indicator 遮罩,但設定 display:flex 後立刻同步呼叫
運算函式,瀏覽器來不及畫出遮罩就先被卡住;使用者在等待期間亂點的觸控事件
會被排進佇列,等運算結束、第一題畫面一出現就立刻誤觸到選項,導致答錯。

用雙重 requestAnimationFrame 確保遮罩真的畫出來後才開始算題,讓期間的誤觸
都被遮罩擋掉;同時停用開始/重玩按鈕防止連點,並把提示文字改成「出題中,
請小等一下」。

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01F3WvXk9X1oZEZvxGDdhvns

按「開始挑戰」後,出題運算在慢速裝置上是同步且吃重的,會卡住主執行緒數秒。
過去雖然已經有 loading-indicator 遮罩,但設定 display:flex 後立刻同步呼叫
運算函式,瀏覽器來不及畫出遮罩就先被卡住;使用者在等待期間亂點的觸控事件
會被排進佇列,等運算結束、第一題畫面一出現就立刻誤觸到選項,導致答錯。

用雙重 requestAnimationFrame 確保遮罩真的畫出來後才開始算題,讓期間的誤觸
都被遮罩擋掉;同時停用開始/重玩按鈕防止連點,並把提示文字改成「出題中,
請小等一下」。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3WvXk9X1oZEZvxGDdhvns

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the game UI to disable start and retry buttons during session generation and introduces a double requestAnimationFrame delay to ensure the loading indicator is fully rendered before heavy synchronous processing begins. The review feedback suggests adding defensive null checks for the loading elements and addresses a potential click-through issue where queued touch events could trigger answers immediately after the loading indicator is hidden, recommending a delayed hide via setTimeout.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread js/game/game-ui.js
Comment on lines 335 to 337
loadingIndicator.style.display = 'none';
showGameView('play');
renderQuestion();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

⚠️ 潛在的點擊穿透問題

generateGameSession 執行期間,若主執行緒因同步運算而卡頓,使用者在此期間點擊螢幕所產生的觸控事件會被瀏覽器排入佇列(Queue)。

由於隱藏遮罩(loadingIndicator.style.display = 'none')與顯示新畫面(showGameView('play')renderQuestion())是在同一個微任務(Microtask)中同步執行的,當這個任務結束後,瀏覽器才會開始分發佇列中的觸控事件。此時遮罩已經被隱藏,導致這些佇列中的點擊事件會直接穿透並觸發在剛出現的第一題選項按鈕上,造成誤觸答錯。

💡 建議解決方案

建議在渲染完題目後,使用 setTimeout 延遲約 300ms 再隱藏遮罩。這樣可以確保所有在卡頓期間累積的點擊事件都會先被遮罩攔截並丟棄,之後遮罩才安全地消失。

Suggested change
loadingIndicator.style.display = 'none';
showGameView('play');
renderQuestion();
showGameView('play');
renderQuestion();
setTimeout(() => {
if (loadingIndicator) loadingIndicator.style.display = 'none';
}, 300);

Comment thread js/game/game-ui.js
Comment on lines +308 to +311
loadingText.textContent = '出題中,請小等一下……';
loadingIndicator.style.display = 'flex';
if (startBtn) startBtn.disabled = true;
if (retryBtn) retryBtn.disabled = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

為了提高程式碼的健壯性(Defensive Programming),建議在存取 loadingTextloadingIndicator 的屬性前先進行空值檢查(Null Check),避免在 DOM 元素不存在時引發 JavaScript 錯誤。

Suggested change
loadingText.textContent = '出題中,請小等一下……';
loadingIndicator.style.display = 'flex';
if (startBtn) startBtn.disabled = true;
if (retryBtn) retryBtn.disabled = true;
if (loadingText) loadingText.textContent = '出題中,請小等一下……';
if (loadingIndicator) loadingIndicator.style.display = 'flex';
if (startBtn) startBtn.disabled = true;
if (retryBtn) retryBtn.disabled = true;

@claude

claude Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review 摘要

這個修法抓得很準。追進去看了 generateGameSession(js/game/question-gen.js:239)才發現關鍵:它雖然是 async function,但內部 await Promise.all(allWords.map(async w => await getProgress(...))) 呼叫的 getProgress(js/game/game-progress.js:11)只是包了一層 Promise 的同步 localStorage.getItem + JSON.parse,中間完全沒有 macrotask 邊界。也就是說整段運算對瀏覽器而言仍是「一整塊同步任務」,微任務鏈跑完前不會 paint——這正是 PR 描述裡「遮罩設定 display:flex 後立刻被同步運算卡住」的成因。用雙重 requestAnimationFrame 在呼叫運算前強制插入一次真正的 paint 邊界,邏輯上是對的,也是這類問題的標準解法。

確認沒問題的地方

  • startBtn.disabled = true 這行在函式一開始、在第一個 await 之前就同步執行,所以不會有「快速連點兩下、第二次點擊仍呼叫到 startSession」的競態——原生 disabled 按鈕本來就不會派發 click 事件。
  • finally 區塊確保成功/失敗兩條路徑都會重新啟用按鈕,沒有漏掉錯誤路徑。
  • index.html 的 cache-busting 版本號 (4.6.84.6.9) 有跟著 game-ui.js 的改動同步更新,符合專案慣例。

小建議(non-blocking)

  1. 順手的效能觀察(不算這個 PR 的範圍)generateGameSessionallWords 裡每一個詞都各自呼叫一次 getProgress,而每次呼叫都重新 JSON.parse 整包 hakkaLearningProgress。詞彙量變大時,這是 O(n) 次重複解析同一份 JSON,可能才是本來運算「吃重」的部分成因。若之後想進一步優化載入速度,可以考慮在迴圈外先 parse 一次、傳進去查表即可,不需要為了這次修法而順便改。
  2. 極端 edge case:若使用者在點下「開始挑戰」的瞬間切到背景分頁,requestAnimationFrame 在分頁不可見時會被瀏覽器暫停,理論上要等分頁回到前景才會繼續往下算題。機率極低、影響也小(頂多是等待變長),沒有必要特別處理,僅供留意。
  3. 目前這個雙重 rAF 的等待寫成 inline Promise,只有一個使用點所以沒有抽成小工具的必要;但如果之後其他「怕卡住主執行緒」的入口(例如切換題型、重新產生詞庫等)也需要同樣的「等遮罩畫出來再算」邏輯,屆時可以考慮抽成一個共用的 waitForNextPaint() helper 再重複使用。

測試

專案目前沒有 DOM/UI 層級的自動化測試(js/game/srs.test.js 是用 node + console.assert 對純函式做的手動腳本,也沒有對應的 CI 執行它),所以這個修法沒有新增測試並不算缺口,跟現有慣例一致。如果方便的話,人工測試時可以特別在「低效能裝置模擬(Chrome DevTools CPU throttling 4x-6x)」下確認:遮罩出現後、在運算完成前狂點畫面,第一題不會被誤觸選到答案。

整體而言這是一個小而精準、註解說明「為什麼」而非「做什麼」的修法,沒有發現會擋 merge 的問題。

@Aiuanyu
Aiuanyu merged commit ebb244a into main Jul 15, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants