遊戲《蔚藍山》教我的程式設計道理

Piglei發表於2020-08-18
如果有這麼一款遊戲,你操作的角色平均每 20 秒就會死亡一次,正常通關一次,總共需要死掉超過 2000 次。你猜這是一款神作還是垃圾?

《Celeste》(譯名:“蔚藍山”)就是這麼一款遊戲。在遊戲裡,你扮演一個名為 Madeline 的女孩,通過跳躍、抓牆、衝刺等動作,去努力登頂一座名為 “Celeste” 的高山。

遊戲《蔚藍山》教我的程式設計道理
圖:《蔚藍山》遊戲畫面,它是一款點陣畫風 2D 平臺動作遊戲

正如我在開頭說的,這款遊戲的難度高到令人髮指,玩家平均得死上千次才能通關。但奇怪的是,這款遊戲獲得的成就似乎和它的難度一樣高。在 2018 發售那年,它獲得了 TGA “年度遊戲”提名併成功拿下了“最佳獨立遊戲”獎項。截止到 2018 年底,它總共賣出了超過 50 萬份。

極低的犯錯成本

讓《蔚藍山》大獲成功的原因有很多。精妙的關卡設計、出色的動作手感、令人驚豔的遊戲配樂,以及劇情裡流露出的真誠人文關懷,都是非常關鍵的因素。但除開這些,我在玩遊戲時,還注意到了一個有意思的細節:在遊戲裡,玩家的犯錯成本非常低。

假如你操作跳躍的時機不對,角色掉入坑裡死掉了。然後,在 不到 3 秒鐘 內, Madeline 就會在房間入口處復活。你可以對自己的打法稍作調整,馬上進行下一次嘗試。

並非所有遊戲都給予了玩家這種快速試錯能力。比如在 PS4 遊戲《血源詛咒》裡,一次死亡可能代表你過去一小時獲得的資源全都化為烏有。

註解:
在《血源詛咒》中,玩家死亡後會喪失當前擁有的所有血之迴響(一種遊戲內資源)。如果要找回它們,你需要從一個又一個怪物堆裡穿過,回到你的死亡地點。如果你在路上再次死掉,那麼那些血之迴響就會全部消失。

所以,在《蔚藍山》裡,遊戲設計者給了玩家一種可以 “低成本犯錯” 的能力。有了它,我們可以快速從錯誤中學習,更好的完成挑戰。那麼,如果用程式設計來類比,我們在寫程式碼時的犯錯成本又如何呢?

程式設計時的“犯錯成本”


假設我在開發一個新聞稿管理系統,系統裡目前只有一種使用者:“管理員”。但因為需求變更,我現在得給系統加上兩個新角色:“編輯”和“主編”。

每類角色能做的事是有區別的:

  • 編輯:可以提交稿件、修改自己的稿件
  • 主編:在編輯的許可權上,增加刊登稿件的功能
  • 管理員:可以做任何事以及管理所有人的許可權

為了支援不同的角色,我需要改進現有的使用者許可權體系。首先,我得把和許可權控制相關的所有功能點整理出來,然後開始寫許可權控制相關的程式碼。

沒人能一次寫出不出錯的程式碼,所以寫程式碼,其實就是一個在不斷重複 “開發” -> “試錯” -> “修改” 的過程:

  • 修改後端程式碼,增加新角色:“主編”
  • 在“主編”相關的功能點,增加許可權保護程式碼片段
  • 儲存程式碼,等待本地伺服器重啟載入改動 (5-10 秒)
  • 開啟瀏覽器,點選各個功能頁面,確認我的改動是否生效 (10 秒以上)
  • 如果測出問題,回到步驟 2,重複整個過程

在很長一段時間裡,我在工作時的開發流程就是上面這樣。我總是在接到需求後就馬上對程式碼修修改改,然後開啟瀏覽器,點點這裡、點點那裡,用肉眼觀察一切是否正常。

使用這種開發方式,假如我某次寫的程式碼有問題,那麼從我每次改完程式碼,到一直走完步驟 3、4、5,整個過程至少得花費超過 30 秒。

如果你不覺得 30 秒很多,請你想想《蔚藍山》吧。在《蔚藍山》裡,角色每次死亡到下次重試的時間間隔是不到 3 秒鐘,二者相差 10 倍。所以,上面這種開發模式的“犯錯成本”太高了。

如何降低“犯錯成本”

其實,在開發這類 web API 時,我們完全沒有必要傻乎乎的手工用瀏覽器點來點去。作為功能的開發者,我們可以(而且有義務)利用自動化測試來加速整個試錯過程。

很多 web 框架都為這類測試提供了幫助。拿 Django 為例,你可以使用 django.test.Client 來輕鬆編寫這類測試:

  1. # 以下程式碼片段來自 Django 官方文件
  2. import unittest
  3. from django.test import Client

  4. class SimpleTest(unittest.TestCase):
  5.     def test_details(self):
  6.         client = Client()
  7.         response = client.get('/customer/details/')
  8.         # 測試某次請求是否返回了 200 狀態碼
  9.         self.assertEqual(response.status_code, 200)
複製程式碼

對於前面的需求,我們可以直接編寫下面這樣的單元測試程式碼。
  1. # 針對不同的角色定義不同的單元測試類

  2. class RoleEditorTestCases(TestCase):
  3.     """編輯角色的測試類
  4.     """

  5.     def test_create_post(self):
  6.         # 編輯角色可以正常呼叫建立帖子介面
  7.         response = self.request_post('/posts/', {'title': 'foo'}, current_user=self.user)
  8.         assert response.status_code == 201
  9.         assert isinstance(response.data, dict)

  10.     def test_create_admin(self):
  11.         # 編輯應該無權呼叫建立管理員介面
  12.         response = self.request_post('/admins/', {'user_id': 100}, current_user=self.user)
  13.         assert response.status_code == 403


  14. class RoleAdminTestCases(TestCase):
  15.     """管理員角色的測試類
  16.     """

  17.     def test_create_admin(self):
  18.         # 管理員可以呼叫建立管理員介面
  19.         response = self.request_post('/admins/', {'user_id': 100}, current_user=self.user)
  20.         assert response.status_code == 201
複製程式碼

有了這些單元測試後,整個試錯流程可以得到極大改進。每當我改完程式碼後,只要執行 pytest 命令跑一遍相關的單元測試,就能知道改動是否奏效了。

  1. ❯ pytest
  2. ======== test session starts ========
  3. platform darwin -- Python 3.8.1, pytest-5.3.5
  4. collected 5 items
  5. tests/api/test_permissions.py .....
  6. ======== 5 passed in 0.72s ========
複製程式碼

不需要等待開發伺服器載入變更、不需要開啟瀏覽器點這點那。一切試錯任務都可以在幾秒鐘之內完成。

編寫測試其實也是 DRY

我在前面說過,在遊戲《蔚藍山》裡,如果角色死掉了,那麼她馬上會從當前這個 房間入口處 重生。讓我們設想一下,假如遊戲沒有采用這種設計:在新機制下,角色每次死亡後,玩家都得回到本章開始的地方,重新挑戰一遍好幾十個已經通過的房間。那會怎麼樣?估計很多人會氣的把手柄摔地上。

但是,依賴人工測試的開發流程,其實就非常接近於讓人摔手柄的設計。

拿使用者許可權功能來說,因為這個功能非常關鍵,所以我每次做出大改動後,都需要重複驗證一下每個功能點在各角色下的表現是否正常。假如系統裡一共有 20 個功能點需要和許可權掛鉤,那麼 20 * 3 個角色,就是 60 個需要測試的點。

即便我有三頭六臂,每個功能點只花 20 秒測試,整套東西測下來也需要 20 分鐘。

但是,如果你已經為這些場景寫好了單元測試,那麼事情就變得簡單多了。每次做了改動之後,你只需要重新執行一遍單元測試,就能把所有場景都驗證一次。

Django 框架有一條設計哲學叫 “Don't repeat yourself (DRY)” - “不要重複你自己”。多數情況下,我們說 DRY 是指不要寫重複程式碼。但我認為“不要重複手工測試已經測過的東西”其實也可以算是 DRY 的一種。

所以,每當你手動測試一次功能時,其實就是在重複你自己。既然如此,何不將它寫成一個單元測試呢?


“所以,就是在勸我寫單元測試?”


是的,我就是在勸你寫單元測試。作為對比,讓我們看看利用單元測試的開發流程是什麼樣的:

  • 修改後端程式碼,增加新角色:“主編”
  • 在“主編”相關的功能點,增加許可權保護程式碼片段
  • 編寫與功能程式碼相關的單元測試程式碼,與 2 同步進行
  • 執行單元測試,如果失敗,從 2 開始調整程式碼,重複整個過程 (幾秒鐘)

通過把測試行為自動化,我們可以大大減少整個開發過程的試錯成本。事實上,自從若干年前養成了寫單元測試的習慣,我就一直堅持至今。那麼,我到底是因為什麼在寫單元測試呢?

  • 單元測試讓我的程式碼 Bug 更少?
  • 單元測試幫助我寫出擴充套件性更強的程式碼?
  • 單元測試讓我在重構時更不容易出錯?
以上可能都是。但現在,我可以往上面的列表裡再加上一點:使用單元測試來開發的過程,有一種流暢感,失敗後就馬上重試,一切就猶如在操作 Madeline 登頂那座蔚藍色的山。

作者:Piglei
來源:Piglei
地址:https://www.zlovezl.cn/articles/what-celeste-teaches-me-about-programming/

相關文章