【導讀】:Evan Todd 用風趣幽默的口吻點評了自己從 2004 年以來做遊戲時寫的程式碼,有 Java、C++、Python。經過十幾年打怪練級,最近終於完整地做完並推出了自己的第一套遊戲。Todd 今年 26 歲。
在一個獨處的星期五晚上,因急需一些靈感,你決定重溫一些你過去「征服」的程式。舊硬碟緩緩地旋轉著,你瀏覽著過去那些光榮歲月裡編寫的程式碼。
噢,不! 這根本不是你所期望的。程式碼真的有這麼糟糕嗎? 為什麼沒有人告訴你?當時為什麼會喜歡這樣寫?有必要在一個功能中寫這麼多的 goto 嗎?很快,你就關閉了這個專案。 有那麼一瞬間,你甚至考慮刪除它,然後清空硬碟。
以下是我對自己過去的程式設計經歷中的一些經驗教訓、程式碼片段和警告的整理。為了暴露錯誤,我沒有對原有的命名進行修改。
2004 年
這一年我十三歲。這個專案取名《紅月》,這是一個雄心勃勃的第三人稱飛行射擊遊戲。在該專案中,幾乎沒有程式碼不是逐字逐句地從《Developing Games in Java》中複製出來的,這樣寫出來的程式碼毫無疑問糟糕透了。讓我們來看一個例子。
我想給玩家設計多武器切換功能。具體方案是將武器模型旋轉到玩家模型的內部,用它變換出下一個武器,然後再將它旋轉回來。以下是動畫程式碼。 別把它想得太難了。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public void updateAnimation(long eTime) { if(group.getGroup("gun") == null) { group.addGroup((PolygonGroup)gun.clone()); } changeTime -= eTime; if(changing && changeTime <= 0) { group.removeGroup("gun"); group.addGroup((PolygonGroup)gun.clone()); weaponGroup = group.getGroup("gun"); weaponGroup.xform.velocityAngleX.set(.003f, 250); changing = false; } } |
我要指出兩個有趣的問題。 首先,這裡涉及了多個狀態變數:
- changeTime
- changing
- weaponGroup
- weaponGroup.xform.velocityAngleX
即使定義了這麼多的變數,仍感覺像是缺少點什麼似的。噢,對了,我們還需要一個變數來跟蹤當前裝備的武器。 當然,這個變數被定義在另一個檔案中。
另一個有趣的問題是,我從來沒有真正建立過一個以上的武器模型。每個武器使用相同的模型。所有的武器模型程式碼只是累贅。
如何改進
刪除多餘的變數。在這個案例中,只需要設定兩個變數:weaponSwitchTimer 和 weaponCurrent。其他一切狀態,都可以從這兩個變數中推演得到。
顯示地初始化一切。 此函式將檢查武器是否為空,並在必要時對其進行初始化。三十秒的觀察期,能夠確保玩家在本遊戲中始終擁有武器。如果沒有武器,則遊戲無法進行,也可能程式會崩潰。
顯然,在某些時候,我在這個函式中遇到了一個空指標異常(NullPointerException)。然而,我並沒有思考為什麼會出現空指標異常,相反地,我只是在函式中插入了一個快速的非空檢查,並讓程式繼續執行。事實上,大多數武器處理函式都進行了這樣的非空檢查!
提前檢查,提前處理! 不要將這些問題留給電腦去解決。
命名
1 |
boolean noenemies = true; // why oh why |
對布林變數進行正向命名。如果你發現自己寫的程式碼也像這樣,那你可能得重新評估一下你的一些「人生決策」了:
1 2 3 |
if (!noenemies) { // are there enemies or not?? } |
錯誤處理
整個程式碼庫中,隨意散落著像這樣的程式碼片段:
1 2 3 4 5 6 |
static { try { gun = Resources.parseModel("images/gun.txt"); } catch (FileNotFoundException e) {} // *shrug* catch (IOException e) {} } |
你可能會認為「應該更優雅地處理這個錯誤!向使用者或某事件傳送訊息。」事實上,我認為剛好相反。
做再多的錯誤檢查都不為過,但一定不要做過多的錯誤處理。在這個例子中,沒有武器模型,遊戲是無法進行的,所以我寧願讓程式崩潰。不要試圖對不可恢復的錯誤進行溫和處理。
這就要求我們提前判定哪些錯誤是可以恢復的。不幸的是,Sun 認為幾乎所有 Java 錯誤都必須是可恢復的,這導致類似於上述例子中的懶惰錯誤處理。
2005-2006年
在這個時間段,我學習了 C++ 和 DirectX。 我決定寫一個可複用的引擎,以便人們可以從我過去 14 年來學到的豐富知識和經驗中獲益。
如果你認為這次也將只是令人尷尬或難為情的,請先保留你的觀點。
當時,我已經學習了物件導向程式設計,它被認為是寫好程式碼的標誌。這導致我寫出以下這種怪物程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
class Mesh { public: static std::list<Mesh*> meshes; // Static list of meshes; used for caching and rendering Mesh(LPCSTR file); // Loads the x file specified Mesh(); Mesh(const Mesh& vMesh); ~Mesh(); void LoadMesh(LPCSTR xfile); // Loads the x file specified void DrawSubset(DWORD index); // Draws the specified subset of the mesh DWORD GetNumFaces(); // Returns the number of faces (triangles) in the mesh DWORD GetNumVertices(); // Returns the number of vertices (points) in the mesh DWORD GetFVF(); // Returns the Flexible Vertex Format of the mesh int GetNumSubsets(); // Returns the number of subsets (materials) in the mesh Transform transform; // World transform std::vector* GetMaterials(); // Gets the list of materials in this mesh std::vector<Cell*>* GetCells(); // Gets the list of cells this mesh is inside D3DXVECTOR3 GetCenter(); // Gets the center of the mesh float GetRadius(); // Gets the distance from the center to the outermost vertex of the mesh bool IsAlpha(); // Returns true if this mesh has alpha information bool IsTranslucent(); // Returns true if this mesh needs access to the back buffer void AddCell(Cell* cell); // Adds a cell to the list of cells this mesh is inside void ClearCells(); // Clears the list of cells this mesh is inside protected: ID3DXMesh* d3dmesh; // Actual mesh data LPCSTR filename; // Mesh file name; used for caching DWORD numSubsets; // Number of subsets (materials) in the mesh std::vector materials; // List of materials; loaded from X file std::vector<Cell*> cells; // List of cells this mesh is inside D3DXVECTOR3 center; // The center of the mesh float radius; // The distance from the center to the outermost vertex of the mesh bool alpha; // True if this mesh has alpha information bool translucent; // True if this mesh needs access to the back buffer void SetTo(Mesh* mesh); } |
我還了解到,註釋也被看作為好程式碼的標誌,這導致我寫出這樣的「瑰寶」:
1 |
D3DXVECTOR3 GetCenter(); // Gets the center of the mesh |
這個類還存在更嚴重的問題。Mesh 的概念是一個令人困惑的抽象,在現實世界沒有參照物。儘管是我寫出來的,但我也對它感到困惑。這是一個容納頂點、索引和其他資料的容器嗎?這是一個用於從磁碟載入和解除安裝資料的資源管理器嗎?這是一個將資料傳送到 GPU 的渲染器嗎?它代表了所有這些東西。
如何改進
Mesh 類應該是一個「普通的舊資料結構」。它應該沒有「智慧」,這意味著我們可以安全地將所有無用的 getters 和 setters 丟棄,並將所有的欄位都設為 public 屬性。
然後,我們可以將資源管理和渲染分離為獨立於惰性資料的系統。是的,是系統,而不是物件。當另一種抽象更合適時,就沒必要將每個問題都轉化為物件導向的抽象。
關於註釋問題的修改,大多數時候,刪除就可以了。由於註釋不受編譯器檢查,容易過時,這是造成誤導的主要因素。我認為不應該對程式碼進行註釋,除非他們屬於以下情況:
- 註釋解釋的是 why,而不是 what。這些註釋是最有用的。
- 用幾句話來解釋下面的大塊程式碼是什麼。這些註釋有助於指導和閱讀程式碼。
- 對宣告的資料結構進行註釋,說明每個欄位的意義。這些註釋往往是不必要的。但有時,欄位與記憶體中的概念的對映關係不能夠直觀顯示,就有必要通過新增註釋來描述這種對映關係。
2007-2008 年
這段時間,是我的「PHP 黑暗歲月」。
2009-2010 年
此時,我正在上大學。我做了一個基於 Python 的第三人稱多人射擊遊戲《 Acquire、Attack、 Asplode、 Pwn》(簡稱 A3P)。關於此專案,我沒有任何理由為自己辯解。形勢真的越來越尷尬了,這個專案帶了一個侵犯健康權益的背景音樂。
(伯小樂注:這個視訊搬運失敗,請看這裡:https://youtu.be/qdt2ixQSjZo )
當我寫這個遊戲的時候,新學到的經驗是全域性變數被認為是糟糕程式碼的標誌。全域性變數提高了程式碼的耦合度。它們允許 A 函式通過修改全域性變數進入完全不相關的 B 函式。 全域性變數無法跨執行緒使用。
然而,幾乎所有的遊戲程式碼都需要訪問整個 world 狀態。我通過將所有內容儲存在「world」物件中,並將「world」傳遞到每個單獨的函式中來「解決」這個問題。 再也沒有全域性變數了!我認為這是「出色的實現」,因為理論上我可以同時執行多個、獨立的「world」。
在實踐中,「world」作為一個事實上的全域性狀態容器。多個「worlds」的想法當然是不需要的,也沒有經過測試,但我相信,如果沒有進行重大的重構,這也不會有效。
一旦你加入了清理全域性變數的奇特「宗教團體」,你會找到很多有創意方法用以欺騙自己。最糟糕的莫過於單例:
1 2 3 4 5 6 7 8 9 10 |
class Thing { static Thing i = null; public static Thing Instance() { if (i == null) i = new Thing(); return i; } } |
哇,魔術啊! 看不到一個全域性變數!然而,單例比全域性變數更糟糕,原因如下:
- 全域性變數的所有潛在缺陷仍存在於單例中。 如果你認為單例不是一個全域性變數,你只不過是在自欺欺人罷了。
- 在最好的情況下,訪問單例只是給你的程式增加了昂貴的分支指令。 在最壞的情況下,這將會是一個完整的函式呼叫。
- 你不知道一個單例會在什麼時候被初始化,直到該程式被真正地執行。這是程式設計師簡單地將本該在設計時應該做出的決策留給程式自己去處理的另一個例子。
如何改進
如果某個變數必須要全域性化,就讓它全域性化好了。在定義全域性變數時,請結合整個專案進行考慮。有些經驗可以借鑑。
真正的問題在於程式碼之間相互依賴。全域性變數,容易使不相關的程式碼之間建立不可見的依賴關係。組合相互依賴的程式碼,併入到內聚的系統中,以最小化這些不可見的依賴關係。實現它的一個好方法,就是將與系統相關的所有內容都放到該系統自己的執行緒中,並強制其它的程式碼通過訊息傳遞與該系統通訊。
布林引數
你可能寫過像這樣的程式碼:
1 2 3 4 5 6 7 |
class ObjectEntity: def delete(self, killed, local): # ... if killed: # ... if local: # ... |
在這裡,我們有四個不同的但又極度相似的「刪除」操作,它們的差異僅僅在於兩個布林引數。看起來似乎完全合理。現在,讓我們來看看呼叫這個函式的客戶端程式碼:
1 |
obj.delete(True, False) |
可讀性很差,不是嗎?
如何改進
這是個案。然而,Casey Muratori 提供的一條建議適用於此:先寫客戶端程式碼。我敢肯定,任何一個有理智的人,都不會寫出上面這種客戶端程式碼。 相反地,你可能會這樣寫:
1 |
obj.killLocal() |
然後,寫出 killLocal() 函式的實現程式碼。
命名
對命名如此多地關注,可能看起來很奇怪。但就像老笑話一樣,這是電腦科學中尚未解決的兩個問題之一。另一個是快取失效和差一 錯誤(off-by-one errors)。
看一下這些函式:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class TeamEntityController(Controller): def buildSpawnPacket(self): # ... def readSpawnPacket(self): # ... def serverUpdate(self): # ... def clientUpdate(self): # ... |
顯然,前兩個函式是相互關聯的,最後兩個函式也是相關的。但是它們沒有通過命名來反映這個事實。 在 IDE 中,這些功能將不會在自動完成選單項中相鄰顯示。
一種更好地命名方式是,以相同的方式開始,並以不同的方式結束。如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class TeamEntityController(Controller): def packetSpawnBuild(self): # ... def packetSpawnRead(self): # ... def updateServer(self): # ... def updateClient(self): # ... |
自動補全對話方塊在顯示這些程式碼時也更加易於理解。
2010-2015年
有了 12 年的程式設計經驗後,我才已完成了一個完整的遊戲專案。
雖然,到目前為止我已經學習了很多程式設計知識,但這個遊戲卻是我所犯下的一些重大錯誤的特輯。
資料繫結
當時,「響應式」UI 框架程式設計之風剛剛興起,像微軟的 MVVM 和 Google 的 Angular 。現在,這種風格的程式設計主要集中在 React 中。
所有這種型別的框架都基於相同的基礎 promise 庫。它們向你展示一個 HTML 文字欄位,一個空的 <span> 標籤元素和一行繫結二者的指令碼程式碼。在文字欄位中鍵入,然後「嘭」! <span> 標籤中的內容不可思議地更新了。
在遊戲的上下文中,它看起來像這樣:
1 2 3 4 5 6 7 8 9 10 11 |
public class Player { public Property<string> Name = new Property<string> { Value = "Ryu" }; } public class TextElement : UIComponent { public Property<string> Text = new Property<string> { Value = "" }; } label.add(new Binding<string>(label.Text, player.Name)); |
哇,現在,UI 會根據玩家的名字自動更新!我可以保持 UI 和遊戲程式碼完全獨立。這是很吸引人的,因為我們通過遊戲的狀態推匯出 UI 狀態,從而消除了遊戲中的 UI 狀態變數。
然而,這裡仍有一些危險訊號。 我不得不將遊戲中的每一個欄位都轉換成一個 Property 物件,該物件包含依賴於它的繫結列表:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
public class Property<Type> : IProperty { protected Type _value; protected List<IPropertyBinding> bindings; public Type Value { get { return this._value; } set { this._value = value; for (int i = this.bindings.Count - 1; i >= 0; i = Math.Min(this.bindings.Count - 1, i - 1)) this.bindings[i].OnChanged(this); } } } |
遊戲中的每個單欄位,甚至最後一個布林值,都附帶了一個不好控制的動態分配陣列。
看一下屬性繫結變更通知的迴圈,就能夠了解我使用這個模式所遇到的問題了。它必須向後迭代繫結列表,因為繫結可以根據實際需要新增或刪除 UI 元素,導致繫結列表更改。
然而,由於我對資料繫結如此熱愛,所以我在進行資料繫結之前建立了整個遊戲。我將物件分解成元件,並將物件的屬性繫結在一起。很快,事情就失控了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
jump.Add(new Binding<bool>(jump.Crouched, player.Character.Crouched)); jump.Add(new TwoWayBinding<bool>(player.Character.IsSupported, jump.IsSupported)); jump.Add(new TwoWayBinding<bool>(player.Character.HasTraction, jump.HasTraction)); jump.Add(new TwoWayBinding<Vector3>(player.Character.LinearVelocity, jump.LinearVelocity)); jump.Add(new TwoWayBinding<BEPUphysics.Entities.Entity>(jump.SupportEntity, player.Character.SupportEntity)); jump.Add(new TwoWayBinding<Vector3>(jump.SupportVelocity, player.Character.SupportVelocity)); jump.Add(new Binding<Vector2>(jump.AbsoluteMovementDirection, player.Character.MovementDirection)); jump.Add(new Binding<WallRun.State>(jump.WallRunState, wallRun.CurrentState)); jump.Add(new Binding<float>(jump.Rotation, rotation.Rotation)); jump.Add(new Binding<Vector3>(jump.Position, transform.Position)); jump.Add(new Binding<Vector3>(jump.FloorPosition, floor)); jump.Add(new Binding<float>(jump.MaxSpeed, player.Character.MaxSpeed)); jump.Add(new Binding<float>(jump.JumpSpeed, player.Character.JumpSpeed)); jump.Add(new Binding<float>(jump.Mass, player.Character.Mass)); jump.Add(new Binding<float>(jump.LastRollKickEnded, rollKickSlide.LastRollKickEnded)); jump.Add(new Binding<Voxel>(jump.WallRunMap, wallRun.WallRunVoxel)); jump.Add(new Binding<Direction>(jump.WallDirection, wallRun.WallDirection)); jump.Add(new CommandBinding<Voxel, Voxel.Coord, Direction>(jump.WalkedOn, footsteps.WalkedOn)); jump.Add(new CommandBinding(jump.DeactivateWallRun, (Action)wallRun.Deactivate)); jump.FallDamage = fallDamage; jump.Predictor = predictor; jump.Bind(model); jump.Add(new TwoWayBinding<Voxel>(wallRun.LastWallRunMap, jump.LastWallRunMap)); jump.Add(new TwoWayBinding<Direction>(wallRun.LastWallDirection, jump.LastWallDirection)); jump.Add(new TwoWayBinding<bool>(rollKickSlide.CanKick, jump.CanKick)); jump.Add(new TwoWayBinding<float>(player.Character.LastSupportedSpeed, jump.LastSupportedSpeed)); wallRun.Add(new Binding<bool>(wallRun.IsSwimming, player.Character.IsSwimming)); wallRun.Add(new TwoWayBinding<Vector3>(player.Character.LinearVelocity, wallRun.LinearVelocity)); wallRun.Add(new TwoWayBinding<Vector3>(transform.Position, wallRun.Position)); wallRun.Add(new TwoWayBinding<bool>(player.Character.IsSupported, wallRun.IsSupported)); wallRun.Add(new CommandBinding(wallRun.LockRotation, (Action)rotation.Lock)); wallRun.Add(new CommandBinding<float>(wallRun.UpdateLockedRotation, rotation.UpdateLockedRotation)); vault.Add(new CommandBinding(wallRun.Vault, delegate() { vault.Go(true); })); wallRun.Predictor = predictor; wallRun.Add(new Binding<float>(wallRun.Height, player.Character.Height)); wallRun.Add(new Binding<float>(wallRun.JumpSpeed, player.Character.JumpSpeed)); wallRun.Add(new Binding<float>(wallRun.MaxSpeed, player.Character.MaxSpeed)); wallRun.Add(new TwoWayBinding<float>(rotation.Rotation, wallRun.Rotation)); wallRun.Add(new TwoWayBinding<bool>(player.Character.AllowUncrouch, wallRun.AllowUncrouch)); wallRun.Add(new TwoWayBinding<bool>(player.Character.HasTraction, wallRun.HasTraction)); wallRun.Add(new Binding<float>(wallRun.LastWallJump, jump.LastWallJump)); wallRun.Add(new Binding<float>(player.Character.LastSupportedSpeed, wallRun.LastSupportedSpeed)); player.Add(new Binding<WallRun.State>(player.Character.WallRunState, wallRun.CurrentState)); input.Bind(rollKickSlide.RollKickButton, settings.RollKick); rollKickSlide.Add(new Binding<bool>(rollKickSlide.EnableCrouch, player.EnableCrouch)); rollKickSlide.Add(new Binding<float>(rollKickSlide.Rotation, rotation.Rotation)); rollKickSlide.Add(new Binding<bool>(rollKickSlide.IsSwimming, player.Character.IsSwimming)); rollKickSlide.Add(new Binding<bool>(rollKickSlide.IsSupported, player.Character.IsSupported)); rollKickSlide.Add(new Binding<Vector3>(rollKickSlide.FloorPosition, floor)); rollKickSlide.Add(new Binding<float>(rollKickSlide.Height, player.Character.Height)); rollKickSlide.Add(new Binding<float>(rollKickSlide.MaxSpeed, player.Character.MaxSpeed)); rollKickSlide.Add(new Binding<float>(rollKickSlide.JumpSpeed, player.Character.JumpSpeed)); rollKickSlide.Add(new Binding<Vector3>(rollKickSlide.SupportVelocity, player.Character.SupportVelocity)); rollKickSlide.Add(new TwoWayBinding<bool>(wallRun.EnableEnhancedWallRun, rollKickSlide.EnableEnhancedRollSlide)); rollKickSlide.Add(new TwoWayBinding<bool>(player.Character.AllowUncrouch, rollKickSlide.AllowUncrouch)); rollKickSlide.Add(new TwoWayBinding<bool>(player.Character.Crouched, rollKickSlide.Crouched)); rollKickSlide.Add(new TwoWayBinding<bool>(player.Character.EnableWalking, rollKickSlide.EnableWalking)); rollKickSlide.Add(new TwoWayBinding<Vector3>(player.Character.LinearVelocity, rollKickSlide.LinearVelocity)); rollKickSlide.Add(new TwoWayBinding<Vector3>(transform.Position, rollKickSlide.Position)); rollKickSlide.Predictor = predictor; rollKickSlide.Bind(model); rollKickSlide.VoxelTools = voxelTools; rollKickSlide.Add(new CommandBinding(rollKickSlide.DeactivateWallRun, (Action)wallRun.Deactivate)); rollKickSlide.Add(new CommandBinding(rollKickSlide.Footstep, footsteps.Footstep)); |
我遇到了一大堆的問題。我建立了繫結迴圈,導致死迴圈。我發現初始化順序常常很重要。然而,初始化是資料繫結的噩夢,一些屬性在新增繫結時被多次初始化。
當新增動畫的時候,我發現資料繫結使得在兩個狀態之間動畫化,變得困難和不直觀。不僅僅是我遇到了這個問題。在解釋任何時候執行動畫都必須關閉資料繫結之前,先看一下視訊 Netflix 的這個演講——人們侃侃而談 React 是多麼偉大的技術。
I我也意識到需要開啟或關閉資料繫結,所以我新增了一個新的變數:
1 2 3 4 |
class Binding<T> { public bool Enabled; } |
不幸的是,這破壞了資料繫結的目的。我想擺脫 UI 狀態,這段程式碼實際上新增了一些。我要怎樣消除這個狀態?
我知道!資料繫結!
1 2 3 4 |
class Binding<T> { public Property<bool> Enabled = new Property<bool> { Value = true }; } |
是的,我真的試了一下。 它一直是繫結著的。 我很快意識到這是多麼的瘋狂。
我們該如何改進資料繫結呢? 嘗試讓你的 UI 變成真正的功能性的和狀態無關的。dear imgui 是一個很好的例子。儘可能分離行為和狀態。避免那些讓建立狀態變得容易的技術。建立狀態是需要付出代價的。
總結
還有很多很多更尷尬的錯誤需要討論。我發現了另一種「創造性」的方法來避免全域性變數;有一段時間,我和閉包糾纏不清;我設計過「實體」、「元件」、「系統」,這種可以是任何東西的物件;我曾試過用多執行緒的方式執行一個體素引擎,通過在每個使用它的地方進行加鎖。
以下是這些經驗的總結:
- 提前處理,而不是簡單地將問題留給電腦。
- 分離行為和狀態。
- 編寫單一功能函式。
- 先寫客戶端程式碼。
- 寫無聊的程式碼。
以上是我的程式設計故事。歡迎大家分享自己的程式設計糗事~