
小時候,每天放學回到家,急沖沖寫完家庭作業,然後我就搬出遊戲機,開始打遊戲。對于 80 後而言,“ 遊戲機 ” 這三個字是一個不言自明的符号,那就是經典的任天堂紅白機,還有一盤盤可以代表幸福指數的遊戲卡。當年誰家的小朋友擁有的遊戲卡多,誰就是當時的紅人,尤其是黑卡,簡直是高端的象征。
得益于父母開明,打遊戲這事我沒受過限制,又得益于我有個偉大的表哥,是以我的幸福指數也從來都是在高位震蕩。經典的《魂鬥羅》《坦克大戰》《綠色兵團》《赤色要塞》《熱血系列》等,冷門的《懲罰者》《聖鬥士》《電梯》等等,包括後來玩的文卡《三國志》,雖不敢說天下遊戲玩了個遍,但也是見過世面的資深玩家了。
父親雖然不限制我,卻常會唠叨“作業做完了就多看看課外書,少玩遊戲。打遊戲浪費時間。” 經常對着我正在玩的《熱血足球》說:“你要踢球就下樓去踢,操作幾個娃娃人在電視上踢有什麼意思?而且你這都是些什麼亂七八糟的,還有星星?!”(玩過熱血足球的應該都知道此刻螢幕上“小辮子”正在放大招)。
但是,父親有時候也會和我一起玩,很投入地玩,而且永遠隻玩一個遊戲——《俄羅斯方塊》。
無數個周末下午,我和父親就是在對戰俄羅斯方塊中度過的。
這款遊戲,對于我而言,有着很深刻的印象,它承載着童年,也承載着父子之情。
《俄羅斯方塊》(Tetris) 誕生于 1984 年 6 月 6 日。當時在蘇聯科學院電算中心工作的科學家 阿列克謝-帕基特諾夫 利用空閑時間設計和編寫了這個落下型益智遊戲的始祖。按理說,這款遊戲其實應該叫做 “蘇聯方塊”。
這款冷戰時期的産物,成了第一個進入美國的蘇聯遊戲。後來還被評為“最偉大的100個遊戲”中的第1名、Gome Boy 史上最受歡迎的遊戲、有史以來最暢銷的電子遊戲,榮譽太多了。
當年初學 C# 時,覺得用 Winform 做點小玩意很快樂,曾想自己做一個俄羅斯方塊。但一想到“方塊旋轉”,“碰撞檢測”,“GDI+ 動畫”等等具體問題,就一頭霧水,感覺難度太大,于是就放棄了。
一放就是好多年。
前不久,忽然有一天,也說不上來什麼原因,又想到了俄羅斯方塊,又萌生了自己動手寫一個的念頭。可能這就是所謂的童年的影響所帶來的執念,不論過多久,總有個牽挂在心裡。
念頭來得容易,但問題依然存在,還是得想辦法解決旋轉、碰撞、動畫等實際問題。
鑒于我的早年經曆,用 C# 來做這事的話,首先就想到 GDI+ ,可我以前學 GDI+ 時就沒有學好,這麼多年過去了,更不會了。我也不從事遊戲開發領域,對于動畫,尤其是處理圖形變換、碰撞檢測這樣的概念,缺乏相關知識儲備。
再一次感到束手無策。
于是我遊離了。
倒也沒遊多遠,在 Wikipedia 上查閱“俄羅斯方塊”這個詞條,想了解一下遊戲的背景。意外地發現了一個有趣的知識:俄羅斯方塊的英文名叫做 Tetris, 這個名稱來自于希臘數字表示 4 的字首 "tetra" 和網球 “tennis” ——兩個詞組合而成。之是以用數字 4 的原因是遊戲中的方塊元素都是由4個方格組成,而網球則是作者最喜歡的運動。
雖然玩了無數遍,但看到這條資訊時我才意識到,原來遊戲中的那些方塊都可以分解成4個小方格組成。
而這個意外的收獲給了我一個啟發:既然每個方塊可以方格化,那麼擴充一下,整個遊戲的構成也可以網格化。
瞬間,事情出現了轉機。
俄羅斯方塊遊戲的視覺呈現可以抽象為兩部分:背景空間和方塊。
将背景空間看成一張表格,方塊可以看成是表格中單元格的組合。
于是,遊戲中任意一個時刻的靜态畫面,可以抽象成:
一張表格中,部分單元格有方塊,部分單元格無方塊,且每一個單元格隻可能處于兩種狀态中的一種。
而遊戲的過程就是将所有時刻的靜态畫面按時間順序“一幀幀”呈現。 也就是說,所有的視覺效果都可以映射為多次更新一張表格中的不同單元格的狀态。
如此一來,之前困擾我的——
“圖形旋轉”就演化成了修改單元格的值。
“碰撞檢測”就隻需要判斷單元格的行列位置。
至于“GDI+繪圖”,就變成了給單元格着色。
時間上,将動态過程切割成離散的靜态畫面;空間上,将連續畫面切割成離散的網格。 解決連續動态的問題頗費周章,但處理一張表格就簡單多了。所謂思路決定出路,大概就是這樣吧。
眼前的障礙消除了,這事感覺可以做下去了。
動手之前,先做計劃。完成這個任務,大緻可分解成以下環節:
設定網格區域
方塊的生成
方塊的移動
方塊的旋轉
方塊的消除
GameOver的判斷
計分
One thing at a time. —— Mark Watney
在開始之前,約定一下思路 —— 遊戲界面由表格提供,表格内的單元格分為3種狀态:
空白,值為0
下落中,值為1
已落定,值為2
對于下落中的方塊,顯示為方塊本來的顔色;對于已落定的方塊,顯示為目前遊戲級别對應的顔色(當年紅白機上的版本就是這麼設定的)。
是以,遊戲畫面的網格化抽象效果如下圖所示:
參考代碼:
狀态常量設定
表格渲染
有了上面這樣一個思路,下面就一步步實作具體的遊戲邏輯。
首先,解決基礎設施的問題——網格。
C#是少有的支援純粹二維數組的語言,但說到表格,沒有哪個對象比 DataTable 更友善了,尤其是配合 UI 元件 DataGridView。
我記得紅白機上的俄羅斯方塊,遊戲的規格是寬10列,高25行(小時候數過,凡是小時候記過的事,好像就不會忘)。是以,最基礎的設施就是一個包含 10 列, 25 行的DataTable. 之後所有的事情都圍繞這個DataTable 來操作了。
定義一個産生 DataTable,同時将内部單元格的值初始化為 0 的方法:
調用該方法産生一個 DataTable 變量,命名為 matrix,這就是整個遊戲的基底:
其中,Game.MATRIX_HEIGHT 和 Game.MATRIX_WIDTH 是常量,分别為 25 和 20,代表着表格的尺寸。
現在,我們有了一個寬25列,高25行,内容全部都是數字 "0" 的表格了。
接下來是核心部件——方塊。
遊戲中需要不停地産生新的方塊,每塊一開始都會出現在頂部,這就需要有一個随機生成方法。
但在處理這個邏輯之前,首先要明确方塊的構成。
前面提到,每個方塊由 4 個方格組成。是以,可以通過一個包含 4 個元素的單元格數組來表示一個方塊,每個單元格包含所在行列的位置索引,通過 4 組行列坐标可以确定一個方塊在網格中的所占區域。額外的,每個方塊有自己的顔色,相當于每個單元格數組中的元素擁有統一的背景色屬性。
俄羅斯方塊一共有 7 種,通常根據其形狀,分别稱作: I, J, L, S, Z, O, T.
利用OOP的思想,我們可以先定義一個抽象類 Tetromino(Tetromino這個詞的意思是“四連正方形”)。該抽象類包含 2 個隻讀的抽象屬性:背景色、初始坐标,1個抽象方法:旋轉。其中,初始坐标這個抽象屬性定義為 Cell 類型的數組。Cell 是一個自定義類,包含 2 個 int 類型的屬性,分别存儲行、列索引。
類關系圖如下:
Tetromino 定義如下:
Cell 定義如下:
然後,分别定義 7 個具體形狀的類,每個均繼承Tetromino,并覆寫其抽象成員。
以方塊 " I " 為例:
其它 6 種方塊類的定義與此類似,略。
接下來,就是實作随機生成方塊的方法了。很顯然,需要用到随機數(Random),可以這麼來做:
定義一個靜态字元串數組,内容就是7個字母 [ "I", "J", "L", "O", "S", "T", "Z" ],然後設定 Random 的範圍恰好就是 0~6 這七個正整數,對應數組的索引範圍。每次随機生成一個數就等于指定了一個字母,最後,執行個體化該字母對應的方塊類就可以了。
方塊字母名稱數組的定義:
生成方法:
方塊造出來之後,下一步就是顯示在遊戲界面中了。這裡所謂的顯示,指的是将方塊初始位置對應的單元格修改為“下落“狀态。
修改單元格的數值的實作方法如下:
至此,方塊的生成邏輯就處理完了。
移動總共有 3 個方向:左、右、下。這裡暗含了一個判斷邏輯——方塊是否能夠移動到目标位置,也就是前文中提到的“碰撞檢測”的作用效果。
在表格化思維的基礎上,所謂“碰撞檢測”其實就是判斷目标位置的單元格内是否已經有方塊了,或者是否超出遊戲界面的邊界了。具體為:
判斷單元格内是否有方塊 = 判斷單元格的數值是否等于表示有方塊的狀态值。
判斷是否超出邊界 = 判斷單元格的坐标是否在表格的行、列範圍内。
針對方塊的移動操作,一定是作用于下落狀态的方塊。是以在執行動作之前,需要先擷取目前正在下落中的方塊的位置資訊。根據前文的約定,處于下落狀态的單元格的數值為 CellStatus.FALL, 通過這個約定,可以擷取下落中的方塊位置。
拿到了下落中方塊的單元格位置資訊,就可以操作向左、向右、向下移動了。
先來看向左移動的情況。
所謂向左移動,實作效果上相當于将 4 個下落中的單元格的列索引減1,行索引不變。其成立的前提條件是:左移之後的 4 個單元格的值均不為 CellStatus.BLOCK, 同時每個列索引大于等于 0.(因為向左移動是朝第 0 列的方向動)
當上面兩個條件都滿足時,就可以把方塊向左平移 1 個機關列寬了。具體操作為:先将目前方塊的單元格數值清零(CellStatus.GAP),然後把目标單元格的值設定為 CellStatus.FALL .
代碼大概長這樣:
右移的邏輯和左移是相似的,差別在于:
右移的具體實作是将單元格的列索引加1。
右移是朝表格的最大列索引方向移動,是以越界的判斷标準是和最大列的列索引比較。
其餘的邏輯是一緻的,略。
再來看方塊的下移。
相較于左右移動,下移的操作是将下落方塊的單元格的行索引加1,而列索引保持不變。“碰撞檢測”的邏輯其實和左右移也類似,唯一的差別就是判斷越界時,比較的是行索引。
不過,相較于左右移動,下移操作含有附加邏輯:每當完成一次下移動作之後,需要立刻判斷該方塊是否“觸底”?并由此決定是否将下落方塊轉換成固定方塊,所謂“觸底”,指的是已經到達遊戲界面的底部或者是觸及到了下方固定方塊。一言蔽之,就是要判斷該方塊是否還能繼續下移。一旦方塊“觸底”,則結束了該方塊的下落周期了,需要将其轉換成固定方塊,并同時通知遊戲産生新方塊。
整個下移的代碼差不多是這樣的:
到這裡,方塊的移動就處理完了。
旋轉是遊戲裡的重頭戲,俄羅斯方塊之是以風靡全球,全靠這個 feature.
而這也是曾經讓我感覺無從下手的地方,受限于鄙人愚鈍的大腦,我完全沒心情去推算圖形旋轉時的數學公式,雖然我覺得這裡應該有一個三角函數上的解,或是其它什麼數學上的玩意,可以一步搞定。(BTW,我數學不好全賴自己,和體育老師沒有關系)
既然正面硬剛沒把握,隻好想别的辦法從側面繞過了。
根據遊戲規則,方塊每次旋轉為 90 度,在紅白機上的操作好像是 A 鍵為順時針轉,B 鍵為逆時針轉(這個不重要)。重點是,每個方塊最多隻有 4 種旋轉形态。
更進一步的,對于 I 形、Z 形、S 型方塊而言,因為形狀本身具有對稱性,是以這 3 個方塊的旋轉形态隻有 2 種。事實上,在紅白機的版本中,當按下旋轉鍵時,這 3 個方塊的旋轉并不是一直朝一個方向旋轉的,而是順時針、逆時針交替進行,即隻有兩種形态變化。
再進一步,O 形方塊不存在旋轉變化,因為它就是一坨。
是以,真正會有 4 種旋轉形态的是 L 形、J 形和 T 形,這 3 種方塊。
也就是說,7 種方塊總共包含: 3 * 4 + 3 * 2 + 0 = 18 (種) 旋轉情形。那麼,完全可以用窮舉法,把 18 種情況逐個實作,就完整地解決了整個遊戲的方塊旋轉問題了。
從變化多的下手。( L 形,J 形,T 形),先說 T 形方塊。
談到旋轉,自然要有一個軸心的概念,每個方塊都存在一個旋轉中心點。T 形方塊的旋轉點是就是橫縱交叉的那個單元格。其 4 種旋轉形态如下圖所示:
如果将軸點單元格的行、列坐标表示為 [ x, y ], 則 圖1 中的方塊坐标可以表示為 { [ x, y-1 ] , [ x, y ] , [ x, y+1 ] , [ x+1, y ] }.
4 種旋轉過程可以演化成如下計算規則:
1 -> 2 : 将方塊的坐标調整成 { [ x-1, y ], [ x, y-1 ], [ x, y ], [ x+1, y ] }.
2 -> 3 : 将方塊的坐标調整成 { [ x-1, y ], [ x, y-1 ], [ x, y ], [ x, y+1 ] }.
3 -> 4 : 将方塊的坐标調整成 { [ x-1, y ], [ x, y ], [ x, y+1 ], [ x+1, y ] }.
4 -> 1 : 将方塊的坐标調整成 { [ x, y-1 ], [ x, y ], [ x, y+1 ], [ x+1, y ] }.
旋轉邏輯就這麼簡單粗暴地搞定了。
且慢!這遊戲裡,任何動作都不能忘了“碰撞檢測”。上面的方法隻提供了如何旋轉,但還缺一個判斷能否旋轉的邏輯。
和移動類似,旋轉的可行性判斷也包含目标單元格是否可用,這個不言自明。
額外的,為保證遊戲效果更符合實體規律,還要包含一個每個單元格在其旋轉路徑中是否有阻擋的判斷?
如圖所示:
上圖中的陰影區域,都屬于旋轉路徑,不能有障礙物存在,否則,方塊在實體上是轉不動的。
和分析旋轉動作的方法類似,判斷旋轉可行性也是以軸心單元格為基礎,通過相對位置逐個判斷單元格是否合法。
代碼實作如下,聲明變量:
1 => 2 :
以上代碼為 T 形方塊從形态 1 旋轉到形态 2 的具體實作,其餘 3 種旋轉情況與此同理,代碼從略。
至此,T 形方塊的旋轉方法就完結了。
L 形、J 形方塊的做法和 T 形是一個道理,無非軸心格還有路徑格的位置不同罷了,沒有本質差別,故略。
接下來看隻有兩種變化的方塊( I 形、S 形、Z 形),就以最受歡迎的 I 形方塊為例。
I 形方塊的軸心在第二塊格子,也就是說它旋轉起來是非對稱的。其旋轉時的單元格變化關系如下:
同樣以軸心單元格為基礎,設橫、縱坐标為 [ x, y ] ,則它的旋轉過程可量化成:
1 -> 2 : 将方塊的坐标調整成 { [ x-1, y ], [ x, y ], [ x+1, y ], [ x+2, y ] }.
2 -> 1 : 将方塊的坐标調整成 { [ x, y-1 ], [ x, y ], [ x, y+1 ], [ x, y+2 ] }.
I 形方塊的旋轉路徑合法性檢測如下:
嚴格來說,上面兩幅圖中的右下角單元格也應該納入“碰撞檢測”的範圍内,但我故意去掉了對這個單元格的限制,因為這樣可以提升遊戲流暢度。
代碼實作如下:
Okay, I 形方塊的旋轉搞定了。
另外兩個同類的 S 形、Z 形,實作原理一樣,略。
現在還剩最後一個 O 形方塊待處理,那就處理掉吧。
Talk is cheap, show me your code. -- Linus Torvalds
廢話少說,直接上碼:
O形方塊,Over.
這可是續命的操作,得好好實作。
其實這一塊的邏輯,相對來說是比較簡單的。
從視覺上來看,當有一行或幾行被消除了,則上方所有的方塊就集體落下一行或幾行,但落下過程并不會改變方塊本身的相對位置。是以,可以将這一過程看成是:被消除的行,先變成空行,然後将該空行從目前位置抽出,并插入到表格的頂部,表格中原先的行自動往下順移。如此就實作了方塊的消除和下落補位的效果了。
主要代碼實作大約長這樣:
Game Over 的判斷
雖然遊戲界面的高度有 25 行,但根據紅白機上的玩法來說,實際遊戲空間的高度在 20 行,當落下任意一個方塊,其高度達到第21行時,就GG了。
根據這個規則,判斷遊戲結束的标準也就是判斷第 21 行是否有固定方塊了(這裡描述時是從底往上數,實際代碼實作時是從頂往下數)。
代碼如下圖所示:
分數其實不是遊戲的必須,前文已經介紹的部分,已經可以組裝起一個可玩的遊戲了。但是,如果俄羅斯方塊沒有分數的話,那就像賭博不帶彩,瞬間沒有存在價值了。是以,分數其實又是遊戲的必須。我和老爸就曾為了争個最高分數,鬥到老媽發飙“你們倆再不來吃飯我把菜都倒垃圾桶!”
按照紅白機上的玩法,俄羅斯方塊的計分規則分為 3 項:
得分
消除行數
等級
一次消除一行,得100分
一次消除兩行,得400分
一次消除三行,得900分
一次消除四行,得2500分
每次發生消除時,得分和消除行數累加。
每累計消除 30 行,遊戲等級增加 1 級。
等級越高,方塊下落速度越快,至于到底提速多少,我也搞不清楚。在我自己做的這個版本裡,我設定為增速10%.
部分代碼:
其中,分數值做成了常量:
遊戲等級更新及分數顯示:
至此,遊戲中所有的業務邏輯都已被實作,最後就剩把這些業務邏輯按照遊戲的玩法組合起來了。
首先,當遊戲開始前,大約需要做以下事情:
生成遊戲界面表格
生成預覽表格(根據紅白機的玩法,遊戲過程應該包含提示下一個方塊的預覽功能)
準備一個定時器
啟動遊戲
代碼參考:
在啟動遊戲階段,大約需要做以下事情:
計數清零
生成新方塊
生成下一個方塊
界面重繪
啟動定時任務
定時任務中包含了方塊的下落操作。而在方塊完成一次下落之後,需要判斷方塊是否可以繼續下落?
如果方塊可以繼續下落,則繼續;
如果已經觸底了,則需要對遊戲界面的狀态進行更新,緊接着判斷遊戲是否結束?
如果遊戲結束,則結束目前遊戲;
如果遊戲未結束,則進行計分操作并開始新的方塊;
定時任務
下落後處理
而在方塊下落的過程中,需要監聽鍵盤事件,完成相應的事件處理:
上:執行旋轉
下:方塊向下移動一行
左:方塊向左移動一列
右:方塊向右移動一列
空格:方塊快速下落(直接一落到底)
F2: 重新開始
F8: 暫停
That's all .
到此,所有的事情都做完了,該玩一把了。
Bug Report
在我以前的一篇文章裡,我說過 " No bug, no code. ",我再補充一句:“沒有例外。”
遊戲能玩,但有兩處遺憾:
1. 當平移或者旋轉方塊時,會中斷方塊的自動下落過程。而在紅白機上方塊會持續下落。
2. 根據我設計的旋轉邏輯,當I形方塊貼着遊戲界面的邊框時,無法旋轉,因為旋轉路徑檢測會不通過。但是在紅白機上的,同樣的情況,可以旋轉,其産生的效果有點像方塊在邊框上打滑了一下然後被擠開。
雖然明知存在瑕疵,但在我一氣呵成寫完整份代碼後,已無心修複。
畢竟能玩了,畢竟,我又不是處女座。
寫在最後
聽說俄羅斯方塊是遊戲開發領域的 " hello world ",這麼說來,我也算入門遊戲設計了?呵呵~
決定寫這個程式是在一個周末的午後,動手前估計着周末兩天應該都得搭進去了。但當我完成核心代碼并玩上第一把的時候,天還沒黑,差不多用了也就一個下午的工夫,精确點說,大約 4 個小時左右。說實在的,我自己都很意外。如果我參加一個面試,要求在 4 個小時的時間内做一個俄羅斯方塊,我肯定直接 “謝謝,再見” 了。不禁回想當年初學 C# 時,就想做這個遊戲,卻淺嘗辄止,竟擱置了這麼久才實作,慚愧。諷刺的是,如今我寫下的這份代碼,完全基于 .NET 2.0 的架構,C# 2.0 的文法,完全沒有超出當年我初學 C# 時的知識體系。
反思之餘,就用一句我很喜歡的英語格言作為結束吧:
WHEN THE GOING GETS TOUGH , THE TOUGH GET GOING .
附
完整代碼(GitHub位址):https://github.com/sherrywasp/tetris.git