標籤:style blog http tar ext com
本文簡述一門課程,示範win32api開發俄羅斯方塊的開發過程。假設學生學習過C語言,沒學過或者學習C++不好,剛剛開始學習win32api程式設計,還不懂訊息迴圈和註冊視窗類別。
最近的照片在這裡 [http://www.douban.com/photos/album/132796665/] 和 [http://www.douban.com/photos/album/133241544/]。
1. 背景和原則
我這學期講一門課,本科三年級,學生滿員17人。一般接近滿員,最低一次5人,那天據林同學說,其他的同學都去看足球賽了。
課程名字叫做演算法與程式設計實踐3。第一堂課我照例要解釋:到了"3"這個階段,就不講演算法了,只有實踐。不過,後來看看演算法也還是有一點應用,比如從一個線性表裡刪除合格元素們,線上性表裡尋找合格元素,這種難度的。
課是在機房上的,大部分時間學生和教師都看著顯示,所以一學期下來,好多同學和我見面可能都不太認識。不過我們對代碼的形成過程更熟悉一些。
我試圖貫徹下述原則:學生應該看到教師編程的過程,而不僅僅是結果;學生應該看到在編輯器和編譯器中的代碼,而不是WORD或PPT裡的;學生應該先學會臨模教師的編程過程,而沒有能力直接臨模結果;學生甚至應該看到教師的錯誤及錯誤的解決過程、教師的無知及檢索過程,學生不應該看到事先排練的完美的編程過程和全知全能的教師,那樣的過程和專家,學生模仿時無從下手。
所以,我課前不準備,在課堂上無意犯各種錯誤--偶爾示範學生們容易犯的錯誤--及解決。在LOG檔案中記錄我們的計劃和當前的進度,在畫圖裡畫下原型。
所以,我假裝對某些API和函數不熟悉,示範在MSDN和互連網中尋找手冊和解決方案的步驟。單獨做一些技術原型驗證對API的調用結果的猜想,而不是在工程的過程中在項目代碼中測試技術。有時,我知道問題在哪裡,但是要先列出各種可能,然後一一驗證猜想(而不是直接解決,這似乎是電腦本科生非常容易犯的錯誤,如果解決了就認定那是問題的原因)。除了這兩點,其餘的時間我應該儘可能誠實。
有時候,學生會告訴我哪裡錯了,先於我發現問題的原因。這令我享受這樣的教學過程。
最終,我們--以我編碼為主--實現了WIN32API開發的俄羅斯方塊。
選擇俄羅斯方塊的原因,是因為小遊戲的商務邏輯足夠複雜,保證學生瞭解在相對複雜的商務邏輯時的面臨的問題和編程行為與toy作品不同;所使用的到技術較少,避免過多的機制 (資料庫、網路等)分散學生的注意力,保證學生把精力集中在對商務邏輯上。
選擇win32api是課堂上投票的結果。選擇C語言而沒有使用C++有兩個原因。一是學生的C++掌握通常並不熟練;二是我希望學生能在項目中發現物件導向的必要性和優點,而不是僅因為學習過哪些語言而在工程中選用;三是希望示範用C也可以實現基於對象的程式設計 (不是物件導向,不包括繼承,僅包括方法與資料的內聚)。
2. 技術原型
涉及到的技術原型,要在工程開始前建立小項目,以驗證對這些技術的掌握和對效果的猜想。
要實驗的技術列表,來源於需求。我們先不寫代碼,口頭描述需求,然後分解需求到所需的技術。這樣就形成了技術列表。這個過程中,同時也形成了定義,包括名詞和動詞表。
這些技術原型也限定了除C語言以外需要掌握的技術,在這次開發當中。
技術原型包括:
* 使用GDI畫圖、擦除。用於畫小塊和移動小塊。移動是根據視覺暫留在新的位置上畫圖,並把舊位置上的小塊以底色重畫。
* 鍵盤訊息響應。用於在不暫停小塊下落的情況下接受玩家通過按鍵操縱小塊左移、右移、旋轉、快速下落。
* 特定範圍的隨機數產生。用於在建立新的小塊時,決定是哪個類型。類型計有S型、L型、凸形、田形,及它們的旋轉。
* 計時器 (timer),用於驅動小塊定時下落,判斷是否該清除一行,計分,重新整理工作區 (重畫) 等。
* 在工作區輸出文字。用於調試和顯示分數。
最終形成的原型部分代碼量如下。代碼在附件中的 prototype目錄下
畫圖 (及訊息迴圈) ,draw,226行
擦除,eraser,263行
在工作區輸出文字,textout,201行
按鍵訊息響應,key,207行
隨機數,random, 31行
計時器,timer,214行
3. 開發過程的裡程碑
技術原型確定以後,再重新回到需求,並把需求排期。爭取每次課程限定完成一個功能。
需求排期遵循的原則是:優先完成對其他功能無信賴的部分;優先完成核心功能。
以下是開發過程中的裡程碑。
1) 產生塊。
2) 計時器驅動,塊自動下降
3) 鍵盤控制塊 旋轉、快速下降、左移、右移
4) 落到底或粘在底部已存在塊上 (if (conficted || touch_bottom) stick)
5) 刪除一行:刪除一行,把之上的行下降一行
6) 計分:消除一行和多行分值不同
以下功能在本學期沒有實現。
7) 產生新塊前,在預覽區顯示下一個塊
8) 分數積累到一定程度 (?),加快塊下落的速度
開發過程以git版本控制方式記錄了曆史,每個重要功能一次commit,以日期作為message。
4. 定義
我們在開發前用約定了一些定義,作為詞彙表。排版原因,我在這裡有文字解釋一下。
俄羅斯方塊元素:工作區上繪圖的最小單位,是一個小方格。俄羅斯方塊的名字 Terris 即四元素,因為每個當前塊由4個元素組成。
數組元素:即C語言中的數組元素,數組中的某一個。提出這個定義是為了區別於俄羅斯方塊的元素。
當前塊 (current block) :正在移動的由四個元素構成的塊。有S型、L型、田字型等類型。
已存在的塊 (exist block) :堆積在工作區底部的,已經粘成一團的元素。
像素座標,全局座標。像素座標是由GDI繪圖定義的,全局座標由我們定義,以元素為單位,左上是原點 (0,0) ,向右向下遞增。
stick。當前塊接觸到已存在的塊,或者當前塊接觸到工作區底部,此時應該把當前塊加入到已存在的塊中,然後產生新的當前塊;如果導致已存在的塊中某一行充滿元素,需要按遊戲規則刪除此行,然後把已存在的塊中此行以上的元素降落一行。
5. 資料結構及流程
以下介紹當前塊、已存在塊、鍵盤操作、刪除已存在塊中的一行的資料結構和流程。
5.1 當前塊
當前塊中,包括當前塊的以下資料:當前座標,上一次的座標 (用以擦除) ,當前類型 (接下來會解釋),上一次的類型 (用於旋轉)。結構體如下,整個程式中只有這個結構體的唯一執行個體。
struct struct_block{
int x;
int y; /* row 0, col 0 */
int old_x;
int old_y;
int* type;
int* old_type;
};
當前塊的類型使用數組實現,如下,分別是一字型、田字型、凸字型。
int line_v_block[]={0, 0, 0, 1, 0, 2, 0, 3};
int line_h_block[]={0,0,1,0,2,0,3,0};
int tian_block[]={0, 0, 0, 1, 1, 0, 1, 1};
int tu_v_block[]={0,1,1,0,1,1,2,1};
int tu_h_block[]={0,1,1,0,1,1,1,2};
數組中的每兩個數值 (資料中的元素)代表一個當前塊中的元素的座標,計8個數值代表4個元素。
產生塊時,
current_block.type = line_v_block;
指定了當前塊的元素。
繪圖時,遍曆"類型數組",把每個元素繪出。無論何種類型,都遵循這一流程,從而實現"以資料作為代碼":類型數組即資料,遍曆"類型數組"、在旋轉時改變類型等即為引擎。
旋轉的程式碼範例,改變類型 (的指標) :
if(current_block.type == line_v_block)
{
current_block.type = line_h_block;
}
平移的程式碼範例,改變橫座標:
current_block.x -= 1;
自動下降的程式碼範例,改變上一次的縱座標和當前縱座標。
if(! is_conflicted() && ! is_touch_bottom())
{
current_block.old_y = current_block.y;
current_block.y = current_block.y + 1;
}
else
{
stick();
generate_block();
}
快速下降:
縱座標 增加 所有元素中到達底部 (或已存在塊中同一橫座標的頂) 的最短距離。
貌似題外話,helper函數:is_conflicted(),判斷當前塊是否接觸到已存在塊;is_touch_bottom(),判斷當前塊是否觸底;匹配橫座標,給出當前塊的底座標;求當前塊距離底部的最短距離。等等。
開發helper函數的目的,是為了使程式整體流程清晰。保障整體清晰的方法之一,是要求每個函數內容不得超過一屏。如果超過了,就需要折解出 helper 函數。在主流程中調用 helper 函數,而把helper函數體移出主流程,這樣主流程代碼長度就下降了。這和小學寫作文的時候,老師要求先拉大綱是一個道理。經常有同學說,在開發過程中會發現新的功能,在開發遇到新的技術,沒有做原型的,因此難以把握大綱。這都說明把握大綱和做計劃的能力還差,需要通過練習來訓練。這和小學生寫著寫著作文發現需要查字典,或者寫跑題了,是一個道理。我們的成長並非認識的字多了,而是能預見到將會用到哪些字 (甚至表達手法、寫作素材)。
此外,在物件導向中,有些的函數會成為game (或者 current block 或者 exist block )的成員函數。這在開發中會認識到,如果它們與資料能內聚在一個類中,該是多麼方便,因此瞭解物件導向的在資訊隱藏方面的優勢。這些函數應歸屬於哪個類,是由哪個類承擔這個責任決定的。
5.2 已存在塊
已存在塊中包括以下資料結構:塊的長度 (事實上,是塊的長度*2,代碼中以橫座標和縱座標作為兩個數組元素) ,已存在塊數組。如下。
int exist_block_size=0;
int exist_block[(maxx+1)*(maxy+1)];
這種資料結構,及當前塊的資料結構,把橫縱座標無差別地,不以結構體地方式放在數組中,在後續開發中帶來了麻煩。不過由於課程時間有限,後來,我未對此做出修改。應該逐漸演化程式結構,形成以元素作為結構體的數組。再開發出一些helper甚至成員函數,遍曆時以俄羅斯方塊元素為單位,而不是當前代碼中的以數組元素為單位。
對已存在塊資料結構操作的函數之一是 stick,用於在當前塊觸底 (或觸及已存在塊)時,把當前塊中的元素移到已存在塊中。
有不少helper函數,基本都是通過遍曆 exist_block,按匹配條件讀其中的座標。包括:匹配橫座標,給出已存在塊的頂座標 int get_exist_block_top(int x)。
5.3 鍵盤操作 & 動作序列
玩家操作塊這一操作,由鍵盤訊息響應開始。我們不在鍵盤響應中處理這一事件,而是只在這裡記住這個動作,加入動作序列中。這是後來的版本。最初的版本,我們也不在鍵盤響應中處理事件,而是調用 block.cpp 中的函數。原則是:凡依賴win32api的,放在 tetris.cpp 中,如 timer, 鍵盤響應,繪圖;凡是與商務邏輯有關,平台無關的,放在 block.cpp 中。接收向上箭頭,是鍵盤響應,平台相關,所以放在 tetris.cpp 中;此時調用的 rotate,用於改變當前塊的類型或座標,平台無關,所以放在 block.cpp 中。
動作序列的資料結構如下。在動作序列數組buffer_action_seq中,數組動作元素
(動作) 的類型是 枚舉 action。
enum action{ action_left=1, action_right=2, action_speed_down=3, action_rotate=4, action_down_auto=5, action_na=0};
action buffer_action_seq[action_size]={action_na};
int buffer_action_cursor = 0;
由玩家觸發鍵盤訊息開始,流程如下。
1)鍵盤訊息響應:
buffer_action_seq[buffer_action_cursor++] = action_rotate;在動作序列中加入一個動作。這對應於設計模式中的 commander 模式要解決的問題。
2)在timer中自動下降
timer中 buffer_action_seq[buffer_action_cursor++] = action_down_auto; 在動作序列中加入一個動作。
3)在timer中觸發WM_PAINT
timer 中 InvalidateRect 觸發 WM_PAINT
4)WM_PAINT中執行動作序列
erase_old_block_seq(hdc);
erase_old_block_seq (hdc) 遍曆動作序列,按每個動作改變當前塊座標,然後擦除由於動作產生的舊塊。遍曆動作序列以後,就完成了自上個 timer 周期以來所有的動作,擦除了這期間產生的所有舊塊。
void erase_old_block_seq(HDC hdc) 片斷如下:
for (i = 0; i < buffer_action_cursor; i++)
{
switch (buffer_action_seq[i])
{
case action_left:
move_left();
erase_old_block(hdc);
break;
在序列裡的每個動作中,move_left 改座標, erase_old_block(hdc) 擦除舊塊.
5)WM_PAINT畫新的當前塊和已存在塊
draw_current_block(hdc);
draw_exist_block(hdc);
因為重繪比計算花費的時間要多,作為效能最佳化,如果當前塊與舊塊座標完全相同,不重畫。
另,另一個版本的動作序列,不使用枚舉和swtich-case,通過把函數作為訊息傳遞給責任者,實現disptach:
void (*next_action)() = move_still;
next_action = move_left
其中 move_left是一個函數。next_action這樣的元素 (類型是函數) 組成一個數組,作為動作序列。執行動作序列時,用下面這樣的代碼:
while ( next_action++ != action_termination )
next_action;
由於 next_action 既是函數,也是數組元素的指標,因此上述代碼不是虛擬碼,而是可以執行的。這類似於 jump table 技術,數組元素的類型函數,可以遍曆數組,執行元素對應的函數。
5.4 刪除一行 & 計分數
每個 timer 中,都調用 void kill_all_full_lines()。它遍曆 exist block,凡符合滿行條件的,調用 kill_block_in_line 刪除該行,調用move_exist_block_down_line 把該行以上的 exist_block 下降一行。
這三個 helper 函數都是通過遍曆 exist block 中的每個元素,匹配座標條件,然後刪除數組元素或者改變數組元素的值。如前所述,由於 exist block 封裝中未使用 俄羅斯方塊元素,所以這些遍曆都寫得非常醜陋。
刪除一行以後,累積刪除的行數。全刪以後,根據刪除的行數進行 switch-case,向全域變數 score 累加分數。在下個timer中,把 score 用 textout 輸出到工作區。
6. 回顧和檢討
6.1 資料結構,封裝,迴圈條件
由於最初的 (也是最終的)資料結構設計偷了懶,後來又沒有足夠的時間修改,此前已經提及兩次,exist block的結構過於貼近平台,而遠離需求。exist block的顆粒度太低,是以 int 為類型的 數組元素,對應於需求中的 俄羅斯方塊元素 中的橫縱座標之一。某個數組元素到底是橫座標還是縱座標,到底是第幾個俄羅斯方塊元素,這些都需要由代碼實現。這樣,按需求寫helper函數的時候,遍曆的元素選取、終止條件,都遇到了麻煩。我在課堂上寫作時需要考慮,有時還會錯。經驗說明,當我需要仔細考慮,或者講述時間較長時,學生聽懂可能已經有相當難度了。終止條件錯誤的bug,在代碼中存在兩三處,導致在 exist block夠多時,即遊戲進行一段時間,工作區中會出現莫名其妙的俄羅斯方塊元素。這個bug在最後階段才解決。
這個故事告訴我們,設計不好,對編碼實現的難度要求就會提高。戰略失誤,戰役和戰鬥就不容易打。領導決策膚淺,要求下屬跑死,結果也是白扯。道理都是一樣的。
6.2 不要對付過去
在開發中間的某堂課,我們發現當前塊移動時後面留了尾跡,擦得不乾淨。這些那堂課快結束了。為了能讓學生在課後重複我課堂上的工作,所以我"對付"了代碼,由局部重新整理改為重新整理整個工作區,包括背景。這樣尾跡表面上清除掉了。
之後,延續了這段"對付"的代碼。直到期末將至,我才發現這段"對付"掩蓋了另一些bug,座標移動的bug導致除非重新整理整個工作區就有尾跡。這個bug在最後階段才解決。
6.3 並行,timer
有文章指出,初學者非常不容易理解的程式概念包括:賦值、遞迴和迭代、並行。本程式中有幾個埋得比較深的bug,是由於我對並行沒有足夠警惕造成的。
timer, 鍵盤響應,WM_PAINT會並行發生。當其中一個未處理完的時候,另一個可能就開始執行;甚至timer未處理完的時候,另一個timer也可能會開始。而這些並行的代碼,都調用了 block.cpp。比如有時導致其中一個正改座標尚未完成,另一個開始重新整理工作區,這樣工作區裡就出現個元素,位置是亂七八糟的。
並行的處理,需要 原子操作、處理序間通訊、避免重入 等概念。上述提到的動作序列,目的之一就是希望擦除舊的當前塊這一動作只在 timer 中發生。
在本課程中,應該不期待學生具備這些作業系統中的知識。不過我還沒有想到該如何設計才能規避這些知識。不過我猜應該類似於不用線程也能設計出貪吃蛇,應該有依賴更淺顯知識的設計手段,比如單純輪詢,而不用事件響應、訊息迴圈。有哪位知道,請賜教,謝謝。
6.4 猜想後,應該先驗證,然後再修改
學生們通常把驗證猜想和實施解決歸約成了一步,我也經常如此。下文中的他們,包括我。
他們觀察到問題,然後做出猜想。這是正常步驟。
但是他們不以實驗驗證猜想是正確的,急急按猜想修改代碼。如果問題消失了,好,他們假設抓住了問題的原因;如果問題還在,就再做個猜想,然後又馬上修改。甚至更糟糕,沒有退回到上一步的起點,就在當前工作代碼上"繼續"修改,讓各個猜想累加起來,最終問題解決的時候甚至不知道是什麼原因。
應該先設計實驗,按猜想的模型,如果怎樣就會怎樣。驗證猜想以後,再去解決。比如假設由於 timer 和 keyboard事件響應 同步導致畫圖混亂,那麼,不應該著爭寫進程通訊,而是 應該先選用簡單粗暴的手段 去除同步,以更大的顆粒度作為原子操作,驗證猜想。如果猜想正確,現象應該有所改變。雖然影響效能和效果,但這並不是準備最終採用的代碼,只是用來驗證猜想的。當猜想驗證以後,再去想效果更好的方案真正解決,比如建立個變數作為號誌。
6.5 不要輕易更換技術方案,試圖繞過問題
這個方面,我最初是發現電腦本科的同學傾向強烈。經常有方案,明明再向前一步就能解決,他們卻在此時換了方案。問為什麼。答:因為這個技術解決不了這個問題。
確定"不"是極其困難的,甚至比確定"能"要難上很多。你不能,並非就能確定這個方案不能。
需要充分瞭解你所使用的技術,對它能夠完成的任務有足夠和明確的自信。同時,對用來替換的方案能解決何種問題,也應該明確。做原型驗證,根據理論推論,這些都是解決之道。見到工具,拿來就用,偏聽偏信別人的評論,就太草率了;一旦發現並非萬能良藥,轉身就去尋找就的手段,這就更草率了。
6.6 版本控制
為了讓學生能看到開發的過程,我上課時用檔案系統做了版本控制,每次課一個目錄,有時壓縮成zip。課程結束以後,一個版本一個版本加入git,然後commit,操作了兩個小時(?),其間又擔心整錯了,苦不堪言。
下次一定要從最開始就做版本控制。還要在 commit 前把 debug, pch, sdf 等二進位垃圾手動刪除。
7. 附件
附件是以git版本控制的代碼及日誌,在這裡[http://download.csdn.net/detail/younggift/7499881]。
protype下是技術原型。
tetris下的是俄羅斯方塊項目本身。早先的版本是VS2010的,最後一天的是VS2012的。你可以僅代碼部分添加進win32工程,以適應你的VS版本,或者dev c++版本。
log0.txt是課堂上的日誌。log1.txt是最後一天前期的日誌。log2.doc是最後一天后期的日誌,因為需要,所以改成用word。
pic.bmp是圖片,用來說明定義的。
branch是一個分支,我忘了它是否加入了 trunk,留在那裡備用,以防遺漏。
--------------------
部落格會手工同步到以下地址:
[http://giftdotyoung.blogspot.com]
[http://blog.csdn.net/younggift]