完成散步統計後,我想再讓這些散步資料有一點實際的用途。
例如使用者第一次完成散步,可以解鎖「第一次散步」;累積散步 10 次,可以解鎖新的成就;連續散步 7 天,也可以有對應的獎勵。
看起來只是增加幾個判斷條件,但真正開始做之後,很快就會遇到一個問題:
成就會一直增加,那這些成就的規則應該放在哪裡?
如果每增加一個成就,就需要修改一次程式,之後維護起來會越來越麻煩。
所以這次我沒有把每個成就直接寫死在程式裡,而是把成就本身設計成資料,讓成就的設定和判斷邏輯可以分開。

如果直接把成就寫在程式裡,最簡單的方式可能會是:
if (walkCount >= 1) {
unlock('first_walk');
}
if (walkCount >= 10) {
unlock('walk_10');
}
if (totalDistance >= 100000) {
unlock('distance_100');
}
這種方式在成就很少的時候沒有什麼問題,但成就一多,這裡就會不斷增加新的判斷。
因此目前構想的成就資料主要包含:
Achievement {
id
key
title
statKey
targetValue
iconUrl
}
例如:
| key | statKey | targetValue |
|---|---|---|
first_walk |
walk_count |
1 |
walk_10 |
walk_count |
10 |
distance_100 |
total_distance |
100000 |
streak_7 |
streak_days |
7 |
其中 statKey 和 targetValue 會告訴程式,這個成就要檢查哪一項統計,以及需要達到多少。
程式就可以統一使用同一套邏輯:
const currentValue = stats[achievement.statKey];
if (currentValue >= achievement.targetValue) {
unlock(achievement);
}
這樣如果要新增「累積散步 50 次」,不需要再增加一個新的 if,只需要新增一筆成就資料:
key: walk_50
statKey: walk_count
targetValue: 50
成就的條件放在資料裡,程式則負責處理共同的判斷方式。
成就定義和使用者是否解鎖是兩件不同的事情。
因此我另外建立:
UserAchievement {
userId
achievementId
unlockedAt
}
Achievement 負責描述「有哪些成就」,UserAchievement 則負責記錄「這個使用者已經拿到哪些成就」。
例如:
Achievement
├── first_walk
├── walk_10
├── distance_100
└── streak_7
UserAchievement
├── user A → first_walk
├── user A → walk_10
└── user B → first_walk
這樣同一個成就可以被不同使用者各自解鎖,而不需要把使用者狀態直接放進 Achievement。
解鎖前也需要確認使用者是否已經取得:
const unlocked = await db.userAchievements.findFirst({
userId,
achievementId,
});
if (!unlocked) {
// 建立解鎖紀錄
}
這樣即使同一個使用者之後再次完成散步,也不會重複建立相同的解鎖紀錄。
另一個問題是:成就檢查到底應該放在哪裡?
最簡單的方式可能是:
finishWalk();
updateStatistics();
checkAchievements();
但這代表散步完成流程開始知道成就的存在。
現在可能只有成就,之後如果又加入每日任務、活動挑戰、通知等功能,散步完成流程就會慢慢變成:
finishWalk();
updateStatistics();
checkAchievements();
checkMissions();
checkChallenges();
sendNotifications();
...
最後散步流程會開始負責越來越多事情。
所以這次我把成就判斷獨立成 Achievement Engine。
散步流程只需要在正式統計更新完成後通知它:
await achievementEngine.check(userId);
至於有哪些成就、哪些已經達成,以及要建立哪些解鎖紀錄,都由 Achievement Engine 處理。
這樣之後修改成就系統時,就不需要一直修改散步完成的核心流程。
statKey 對應統計資料把成就條件放進資料後,還有一個問題:程式要怎麼知道每個成就應該檢查哪一項統計?
這就是 statKey 的用途。
例如:
{
key: 'walk_10',
statKey: 'walk_count',
targetValue: 10
}
Achievement Engine 取得成就後,就可以透過 statKey 找到對應的統計:
const currentValue = stats[achievement.statKey];
if (currentValue >= achievement.targetValue) {
unlock(achievement);
}
因此不同成就可以共用同一套判斷方式。
例如:
| 成就 | statKey |
目前數值 | 目標 |
|---|---|---|---|
| 散步 10 次 | walk_count |
12 | 10 |
| 累積 100 公里 | total_distance |
86 | 100 |
| 連續散步 7 天 | streak_days |
8 | 7 |
只要目前數值達到 targetValue,就可以解鎖。
這裡也把兩件事情分開了:
walk_count、total_distance、streak_days
Achievement Engine 不需要知道統計數值是怎麼算出來的,只需要拿到最後的結果。
這也是實作時比較需要考慮的地方。
像「散步 10 次」很好判斷:
walk_count >= 10
但如果未來出現:
就需要額外的統計資料。
所以未來可以繼續增加不同的 statKey:
walk_count
total_distance
streak_days
recommended_route_count
night_walk_count
park_walk_count
例如:
散步資料
↓
Statistics
↓
night_walk_count = 10
↓
Achievement Engine
↓
night_walk_10 已達成
Achievement Engine 不需要知道「晚上散步」是怎麼判斷的,只要 Statistics 已經提供 night_walk_count,就可以使用同樣的成就判斷邏輯。
這樣之後如果調整 night_walk_count 的計算方式,也不需要修改成就系統。
我沒有選擇每次開啟 App 都重新檢查所有成就,而是在散步完成、正式統計更新後檢查。
原因很簡單:成就依賴的是統計資料,如果統計沒有變化,就沒有必要一直重新檢查。
而目前 TraceWalk 的正式散步統計是在散步結束後由後端重新計算,所以這時候檢查成就最合理。
finishWalk();
updateStatistics();
achievementEngine.check();
這樣也能確保成就是根據正式統計結果判斷,而不是根據 App 當下暫存的數字。
例如散步過程中 App 顯示距離 9.8 公里,但後端重新計算後正式結果是 10.1 公里,那麼成就應該以後端最後確認的數值為準。
成就系統還有一個容易忽略的地方:解鎖之後,要知道這一次到底新增了哪些成就。
例如使用者第一次完成散步:
walk_count = 1
這次可能解鎖:
first_walk
但第 10 次散步時,可能一次達成多個條件:
walk_10
distance_10
streak_7
所以 Achievement Engine 不應該只是回傳:
true
而是回傳這次實際新增的成就:
[
'walk_10',
'distance_10',
'streak_7'
]
這樣前端就可以根據結果決定要不要顯示解鎖動畫、Toast 或通知。
也就是說,UI 不需要自己重新判斷:
if (walkCount >= 10) {
...
}
它只需要知道:
const unlockedAchievements = await achievementEngine.check(userId);
如果有新的成就,就顯示對應的 UI。
到這裡,前面的設計就串起來了。
例如現在想增加「累積散步 50 次」,只需要新增:
key: walk_50
statKey: walk_count
targetValue: 50
想增加「累積散步 100 公里」,則是:
key: distance_100
statKey: total_distance
targetValue: 100000
只要 Statistics 已經提供對應的數值,Achievement Engine 完全不需要增加新的判斷。
也就是說,新增成就時主要修改的是成就資料,而不是原本散步完成流程裡的程式。
當成就從幾個慢慢增加到幾十個時,這個差異就會很明顯。
成就系統真正麻煩的地方,其實不是判斷:
if (currentValue >= targetValue)
而是怎麼避免成就越做越多之後,整個程式也跟著越來越難維護。
所以這次我把它拆成三個部分:
Achievement:定義有哪些成就UserAchievement:記錄使用者已解鎖的成就Achievement Engine:負責根據統計資料判斷並解鎖這樣成就條件可以放在資料裡,判斷邏輯則集中管理。
對現在只有幾個成就的 App 來說,這樣的設計看起來可能有點多餘,但當功能開始增加後,就能避免每新增一個成就,就再往散步流程裡塞一個 if...else。