1. 程式人生 > >13 年來,我寫了這些糟糕的遊戲程式碼

13 年來,我寫了這些糟糕的遊戲程式碼

【導讀】:Evan Todd 用風趣幽默的口吻點評了自己從 2004 年以來做遊戲時寫的程式碼,有 Java、C++、Python。經過十幾年打怪練級,最近終於完整地做完並推出了自己的第一套遊戲。Todd 今年 26 歲。

在一個獨處的星期五晚上,因急需一些靈感,你決定重溫一些你過去「征服」的程式。舊硬碟緩緩地旋轉著,你瀏覽著過去那些光榮歲月裡編寫的程式碼。

噢,不! 這根本不是你所期望的。程式碼真的有這麼糟糕嗎? 為什麼沒有人告訴你?當時為什麼會喜歡這樣寫?有必要在一個功能中寫這麼多的 goto 嗎?很快,你就關閉了這個專案。 有那麼一瞬間,你甚至考慮刪除它,然後清空硬碟。

以下是我對自己過去的程式設計經歷中的一些經驗教訓、程式碼片段和警告的整理。為了暴露錯誤,我沒有對原有的命名進行修改。

2004 年

這一年我十三歲。這個專案取名《紅月》,這是一個雄心勃勃的第三人稱飛行射擊遊戲。在該專案中,幾乎沒有程式碼不是逐字逐句地從《Developing Games in Java》中複製出來的,這樣寫出來的程式碼毫無疑問糟糕透了。讓我們來看一個例子。

我想給玩家設計多武器切換功能。具體方案是將武器模型旋轉到玩家模型的內部,用它變換出下一個武器,然後再將它旋轉回來。以下是動畫程式碼。 別把它想得太難了。

12345678910111213 publicvoidupdateAnimation(longeTime){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 booleannoenemies=true;// why oh why

對布林變數進行正向命名。如果你發現自己寫的程式碼也像這樣,那你可能得重新評估一下你的一些「人生決策」了:

123 if(!noenemies){// are there enemies or not??}

錯誤處理

整個程式碼庫中,隨意散落著像這樣的程式碼片段:

123456 static{try{gun=Resources.parseModel("images/gun.txt");}catch(FileNotFoundExceptione){}// *shrug*catch(IOExceptione){}}

你可能會認為「應該更優雅地處理這個錯誤!向用戶或某事件傳送訊息。」事實上,我認為剛好相反。

做再多的錯誤檢查都不為過,但一定不要做過多的錯誤處理。在這個例子中,沒有武器模型,遊戲是無法進行的,所以我寧願讓程式崩潰。不要試圖對不可恢復的錯誤進行溫和處理。

這就要求我們提前判定哪些錯誤是可以恢復的。不幸的是,Sun 認為幾乎所有 Java 錯誤都必須是可恢復的,這導致類似於上述例子中的懶惰錯誤處理。

2005-2006年

在這個時間段,我學習了 C++ 和 DirectX。 我決定寫一個可複用的引擎,以便人們可以從我過去 14 年來學到的豐富知識和經驗中獲益。

如果你認為這次也將只是令人尷尬或難為情的,請先保留你的觀點。

當時,我已經學習了面向物件程式設計,它被認為是寫好程式碼的標誌。這導致我寫出以下這種怪物程式碼:

C++
12345678910111213141516171819202122232425262728293031323334 classMesh{public:staticstd::list<Mesh*>meshes;// Static list of meshes; used for caching and renderingMesh(LPCSTR file);// Loads the x file specifiedMesh();Mesh(constMesh&vMesh);~Mesh();voidLoadMesh(LPCSTR xfile);// Loads the x file specifiedvoidDrawSubset(DWORD index);// Draws the specified subset of the meshDWORD GetNumFaces();// Returns the number of faces (triangles) in the meshDWORD GetNumVertices();// Returns the number of vertices (points) in the meshDWORD GetFVF();// Returns the Flexible Vertex Format of the meshintGetNumSubsets();// Returns the number of subsets (materials) in the meshTransform transform;// World transformstd::vector*GetMaterials();// Gets the list of materials in this meshstd::vector<Cell*>*GetCells();// Gets the list of cells this mesh is insideD3DXVECTOR3 GetCenter();// Gets the center of the meshfloatGetRadius();// Gets the distance from the center to the outermost vertex of the meshboolIsAlpha();// Returns true if this mesh has alpha informationboolIsTranslucent();// Returns true if this mesh needs access to the back buffervoidAddCell(Cell*cell);// Adds a cell to the list of cells this mesh is insidevoidClearCells();// Clears the list of cells this mesh is insideprotected:ID3DXMesh*d3dmesh;// Actual mesh dataLPCSTR filename;// Mesh file name; used for cachingDWORD numSubsets;// Number of subsets (materials) in the meshstd::vector materials;// List of materials; loaded from X filestd::vector<Cell*>cells;// List of cells this mesh is insideD3DXVECTOR3 center;// The center of the meshfloatradius;// The distance from the center to the outermost vertex of the meshboolalpha;// True if this mesh has alpha informationbooltranslucent;// True if this mesh needs access to the back buffervoidSetTo(Mesh*mesh);}

我還了解到,註釋也被看作為好程式碼的標誌,這導致我寫出這樣的「瑰寶」:

C++
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)。關於此專案,我沒有任何理由為自己辯解。形勢真的越來越尷尬了,這個專案帶了一個侵犯健康權益的背景音樂。

當我寫這個遊戲的時候,新學到的經驗是全域性變數被認為是糟糕程式碼的標誌。全域性變數提高了程式碼的耦合度。它們允許 A 函式通過修改全域性變數進入完全不相關的 B 函式。 全域性變數無法跨執行緒使用。

然而,幾乎所有的遊戲程式碼都需要訪問整個 world 狀態。我通過將所有內容儲存在「world」物件中,並將「world」傳遞到每個單獨的函式中來「解決」這個問題。 再也沒有全域性變量了!我認為這是「出色的實現」,因為理論上我可以同時執行多個、獨立的「world」。

在實踐中,「world」作為一個事實上的全域性狀態容器。多個「worlds」的想法當然是不需要的,也沒有經過測試,但我相信,如果沒有進行重大的重構,這也不會有效。

一旦你加入了清理全域性變數的奇特「宗教團體」,你會找到很多有創意方法用以欺騙自己。最糟糕的莫過於單例:

12345678910 classThing{staticThingi=null;publicstaticThing Instance(){if(i==null)i=newThing();returni;}}

哇,魔術啊! 看不到一個全域性變數!然而,單例比全域性變數更糟糕,原因如下:

  • 全域性變數的所有潛在缺陷仍存在於單例中。 如果你認為單例不是一個全域性變數,你只不過是在自欺欺人罷了。
  • 在最好的情況下,訪問單例只是給你的程式增加了昂貴的分支指令。 在最壞的情況下,這將會是一個完整的函式呼叫。
  • 你不知道一個單例會在什麼時候被初始化,直到該程式被真正地執行。這是程式設計師簡單地將本該在設計時應該做出的決策留給程式自己去處理的另一個例子。

如何改進

如果某個變數必須要全域性化,就讓它全域性化好了。在定義全域性變數時,請結合整個專案進行考慮。有些經驗可以借鑑。

真正的問題在於程式碼之間相互依賴。全域性變數,容易使不相關的程式碼之間建立不可見的依賴關係。組合相互依賴的程式碼,併入到內聚的系統中,以最小化這些不可見的依賴關係。實現它的一個好方法,就是將與系統相關的所有內容都放到該系統自己的執行緒中,並強制其它的程式碼通過訊息傳遞與該系統通訊。

布林引數

你可能寫過像這樣的程式碼:

Python
1234567 classObjectEntity:defdelete(self,killed,local):# ...ifkilled:# ...iflocal:# ...

在這裡,我們有四個不同的但又極度相似的「刪除」操作,它們的差異僅僅在於兩個布林引數。看起來似乎完全合理。現在,讓我們來看看呼叫這個函式的客戶端程式碼:

Python
1 obj.delete(True,False)

可讀性很差,不是嗎?

如何改進

這是個案。然而,Casey Muratori 提供的一條建議適用於此:先寫客戶端程式碼。我敢肯定,任何一個有理智的人,都不會寫出上面這種客戶端程式碼。 相反地,你可能會這樣寫:

Python
1 obj.killLocal()

然後,寫出 killLocal() 函式的實現程式碼。

命名

對命名如此多地關注,可能看起來很奇怪。但就像老笑話一樣,這是電腦科學中尚未解決的兩個問題之一。另一個是快取失效和差一 錯誤(off-by-one errors)。

看一下這些函式:

Python
12345678910111213 classTeamEntityController(Controller):defbuildSpawnPacket(self):# ...defreadSpawnPacket(self):# ...defserverUpdate(self):# ...defclientUpdate(self):# ...

顯然,前兩個函式是相互關聯的,最後兩個函式也是相關的。但是它們沒有通過命名來反映這個事實。 在 IDE 中,這些功能將不會在自動完成選單項中相鄰顯示。

一種更好地命名方式是,以相同的方式開始,並以不同的方式結束。如下所示:

Python
12345678910111213 classTeamEntityController(Controller):defpacketSpawnBuild(self):# ...defpacketSpawnRead(self):# ...defupdateServer(self):# ...defupdateClient(self):# ...

自動補全對話方塊在顯示這些程式碼時也更加易於理解。

2010-2015年

有了 12 年的程式設計經驗後,我才已完成了一個完整的遊戲專案。

雖然,到目前為止我已經學習了很多程式設計知識,但這個遊戲卻是我所犯下的一些重大錯誤的特輯。

資料繫結

當時,「響應式」UI 框架程式設計之風剛剛興起,像微軟的 MVVM 和 Google 的 Angular 。現在,這種風格的程式設計主要集中在 React 中。

所有這種型別的框架都基於相同的基礎 promise 庫。它們向你展示一個 HTML 文字欄位,一個空的 <span> 標籤元素和一行繫結二者的指令碼程式碼。在文字欄位中鍵入,然後「嘭」! <span> 標籤中的內容不可思議地更新了。

在遊戲的上下文中,它看起來像這樣:

123