iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
佛心分享-SideProject30

30 天開發一款真正能每天使用的散步 App系列 第 18

設計一套可以持續增加的散步成就系統

  • 分享至 

  • xImage
  •  

完成散步統計後,我想再讓這些散步資料有一點實際的用途。

例如使用者第一次完成散步,可以解鎖「第一次散步」;累積散步 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

其中 statKeytargetValue 會告訴程式,這個成就要檢查哪一項統計,以及需要達到多少。

程式就可以統一使用同一套邏輯:

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,就可以解鎖。

這裡也把兩件事情分開了:

  • Statistics 負責計算 walk_counttotal_distancestreak_days
  • Achievement Engine 負責判斷這些數值有沒有達到成就條件

Achievement Engine 不需要知道統計數值是怎麼算出來的,只需要拿到最後的結果。

但不是所有成就都只是「大於某個數字」

這也是實作時比較需要考慮的地方。

像「散步 10 次」很好判斷:

walk_count >= 10

但如果未來出現:

  • 在晚上散步 10 次
  • 完成 5 條推薦路線
  • 在公園散步 3 次

就需要額外的統計資料。

所以未來可以繼續增加不同的 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 公里,那麼成就應該以後端最後確認的數值為準。

解鎖不是只有一個 Boolean

成就系統還有一個容易忽略的地方:解鎖之後,要知道這一次到底新增了哪些成就。

例如使用者第一次完成散步:

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


上一篇
累積散步紀錄,完成散步統計頁
下一篇
散步越久地圖越卡?GPS 軌跡的效能優化
系列文
30 天開發一款真正能每天使用的散步 App19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言