跳過現成的筆順函式庫——結果「夠接近」這個門檻試了三輪才對
注音符號只有 1 到 4 筆,簡單到可以自己刻描寫判定,不用整套為完整漢字打造的函式庫。要不要自建的決定很好下;但要判定小孩的手指要畫到多接近才算「對」,卻試了三輪——前兩輪還是往相反方向各錯一次。
背景
TraceMode(寫寫看)是 bopomofokids.com 的手指 描寫模式——小孩在觸控或滑鼠介面上,依正確順序畫出每個注音符號的筆畫。筆畫幾何 資料本身是白撿的:教育部《國語注音符號手冊》的開放部件套件——跟這個 App 讀音 音檔同一份來源——裡就有每個符號逐筆的外框跟中心軌跡,座標落在一個 0-2048 的 正方形裡,完全不用做任何轉換就能正確渲染——在寫任何一行轉換程式碼之前,先截圖 驗證過原始資料才確認的。
比較難的問題不是筆畫資料從哪來,而是要自己刻繪圖擷取跟判分邏輯,還是採用
hanzi-writer——一個現成、成熟、專門做這種筆順動畫跟描寫測驗的函式庫。它其實
在規劃階段就真的裝上去測過,確認可以動,包括它的字型基線座標系統需要的縮放跟
Y 軸翻轉。它是可行的。最後還是決定不用它。
繪圖擷取本身反而完全不需要特殊處理:React 的 Pointer Events 原生統一了滑鼠跟 觸控,所以沒有裝置判斷分支——只有一個容錯門檻,要同時適用小孩的手指跟家長的 滑鼠。
光靠距離,分不出方向
判分一筆畫的做法,是拿畫出來的軌跡跟參考軌跡比平均點對折線距離——小孩畫的每個 點,平均離該走的線有多遠。這能抓到手抖跟不精準,但完全抓不到方向,因為距離的 平均值沒有順序的概念:一筆從終點畫回起點、完全反過來畫的筆畫,跟正著畫的比對 結果會一模一樣準。
對一個整個存在目的就是教正確筆順的 App 來說,這不是小漏洞——一筆反著畫、卻被 判定「正確」還播放成功動畫,等於一邊教錯筆順一邊告訴小孩你做對了。有真的使用者 在實測時抓到這件事(一筆從尾畫到頭的筆畫被判定過關),修法是加一道獨立的第二 道檢查:
function isReversed(drawn: Point[], track: Point[]): boolean {
const first = drawn[0];
const start = track[0];
const end = track[track.length - 1];
const distToStart = Math.hypot(first.x - start.x, first.y - start.y);
const distToEnd = Math.hypot(first.x - end.x, first.y - end.y);
return distToEnd < distToStart;
}
src/components/TraceMode.tsx — isReversed
這個檢查刻意做得很簡單——只看「畫下去的第一個點,離哪個端點比較近」——而不是做 完整的路徑方向分析,因為這樣就夠了:37 個符號共 111 筆畫裡,每一筆的軌跡起點跟 終點至少相距 469 個 viewBox 單位,這是對照真實資料檢查過的,不是憑空假設的。 有這麼大的margin,「離哪個端點比較近」就完全不會混淆。
試了三輪才對的門檻——而且前兩輪犯的是同一種錯
平均距離分數需要一個切點:平均偏差到多少算「夠接近」。這一個數字經歷了三個真實 的值,程式碼裡留下的註解對原因寫得很坦白:
// 45 抓得太緊了:誠實、真的在努力畫的筆畫,
// 只是稍微超出門檻一點點就被判掉。合成的手抖建模
// 低估了一隻真的手/滑鼠、就算誠實地想沿著線畫,
// 自然會偏移多少 —— 放寬到 80,
// ...
// 之後要再調,靠真機實測,不要再靠合成建模
// —— 這個管道已經錯過兩次了。
const ACCURACY_THRESHOLD = 80;
src/components/TraceMode.tsx — 門檻自己的歷史,寫在程式碼裡
完整的歷程是這樣的:一開始是 320,真機實測顯示這寬鬆到荒謬的地步——沿著一
個單一線條的簡單筆畫(比如 ㄧ)亂畫出一條彎彎曲曲的線,不管線條本身多不平滑都
照樣判定正確。針對這個問題,靠合成手勢建模重新調到 45,同時加了一道獨立的
第二道檢查(isTooWandering,一個路徑長度比對),抓距離判定漏掉的來回塗抹
動作。
結果 45 是往完全相反的方向調錯了。下一次修法的 commit message 講得很直白:
使用者實測 PR 時的真機回饋:誠實、真的在努力畫的筆畫,只是稍微超出前一個 門檻一點點就被判掉。用來選出 45 這個值的合成手勢建模(自然手抖頂多到約 20 個單位),低估了一隻真的手/滑鼠、就算誠實地想沿著線畫,自然會偏移多少—— 這是這個判分邏輯第二次單靠合成建模判斷錯真機的實際行為。
最後定案的 80,是拿真的瀏覽器驗證的,不是再做一次合成手勢的數學建模: 中等到誇張的波浪形狀依然會被正確判掉,誠實但相當手抖(約 6% 抖動)的輸入現在 會過關,乾淨的嘗試更是輕鬆過關。這裡也有意識地接受了一個取捨——原本建模時找到 的那個最溫和的波浪形狀,現在也會過關,這被判定成一個可以接受的代價,對應這個 App 對真實小孩「不讓人挫折」的原則,而不是一個殘留的 bug。
一個要到第二批才現形的轉換腳本坑
37 個符號分兩批上線:先做 7 個單筆畫符號,專門用來驗證整條流程值不值得繼續投資,
等玩法實測、上面那些問題都修完,才把剩下的 30 個轉進來。筆畫資料轉換腳本
(scripts/generate-stroke-data.mjs)要處理 MOE 原始資料的兩種外框控制點
形狀——二次(Q)跟三次(C)貝茲曲線控制點:
else if (pt.type === 'Q') d += `Q${pt.begin.x} ${pt.begin.y} ${pt.end.x} ${pt.end.y} `;
// 三次貝茲曲線 —— 只在 30 個符號的擴充批次裡出現過
// (第一批 7 個符號完全沒踩到這個情況)。
else if (pt.type === 'C') d += `C${pt.begin.x} ${pt.begin.y} ${pt.mid.x} ${pt.mid.y} ${pt.end.x} ${pt.end.y} `;
else throw new Error(`unknown outline point type: ${pt.type}`);
scripts/generate-stroke-data.mjs — outlineToPathD
第一批 7 個符號的驗證批次剛好只碰到 Q 型的點;C 型的點要到剩下 30 個符號
轉換進來才出現。這個腳本遇到不認得的點類型會直接丟出錯誤,而不是憑空猜——所以
一個從沒碰到某種資料形狀的小型驗證批次,不等於已經證明轉換器能處理那種形狀,
它只是還沒有機會在那上面出錯而已。
之後,37 個符號共 111 筆畫,每一筆都個別截圖、對照 MOE 原始資料核對過——外框 跟中心軌跡疊圖比對——而不是轉換器跑完沒噴錯就直接信任它。
這次學到什麼
hanzi-writer 是可行的,連座標轉換都驗證過了。決定自己刻的理由,是把工具的
複雜度對齊實際問題的複雜度——一個為完整漢字的整個問題空間調校過的函式庫,帶
的是為一個遠比 1 到 4 筆的注音符號更大的問題設計的判分跟調校旋鈕。
平均點對折線距離在時間上是對稱的;它分不出一筆正著畫的筆畫跟同一筆反著畫的 差別。當順序對教學內容本身很重要時,這必須是一道獨立、明講出來的檢查,不能 假設「距離分數夠緊」就代表執行順序也對。
320 跟 45 都是靠合成手勢建模推出來的,而且往相反方向各錯一次。最後定案的修法 來自去看真的瀏覽器裡的實際行為,而不是把同一種模型再精修第三次。同一個方法 連續失敗兩次,不是代表你需要它的第三個迭代版本——是代表這個方法本身就看不到 你想量測的東西。
第一批 7 個符號跑成功,不代表貝茲曲線處理邏輯是對的——只代表那一批裡剛好沒有 三次曲線。把剩下的資料轉進來,才是真正測試到那條路徑,而且它是大聲失敗(丟出 錯誤),不是悄悄產生一個錯的形狀。