1042 Quiz#2 俄羅斯方塊 (Tetris) 遊戲初步

C++ 實習測試: 俄羅斯方塊 (Tetris) 遊戲預備類別製作
時間: 180分鐘 (12:05 上傳時間截止, 不管你做到什麼地方, 請一定要上傳)

經過了半個學期, 希望你已經逐漸熟悉怎樣運用現有函式庫提供的類別來簡化你自己的設計, 也希望你能夠了解到封裝的意義, 逐步開始設計適當封裝的類別, 逐步把你自己的應用程式分割成一個一個類別, 讓這些類別的物件互相合作

你開始在練習設計多個類別時, 一定會發現各個類別的介面很難一次就就設計好, 通常一邊實作程式的功能, 一邊替各個類別設計介面, 有的時候甚至修改先前實作好的介面, 你一定也會懷疑這樣反覆的過程真的是對的嗎? 沒錯, 就是這樣, 你在能夠把各個類別裡面的資料完全封裝起來之前, 程式是會經過相當時間的調整的, 後續我們還會看到許多這樣的過程, 這也是為什麼這學期從一開始實習就介紹如何製作類別的單元測試碼, 目的就是為了在設計調整時能夠保證先前測試過的功能都有正確的表現。

也許你覺得前半學期所寫的程式 (實習和作業) 實在不怎麼有趣, 接下來替大家安排的作業是俄羅斯方塊, 希望在這個作業裡你能夠藉由設計互動式的介面來找回一點動力, 當然還是希望你透過這樣的程式再一次練習設計封裝良好的類別。

作業的目標是: 文字界面 gcc492 , WinBGI vc2010, WinBGI gcc492

不過經過了兩次作業的繳交以及期中考試, 實在令人擔心接下來這次作業的情況, 所以在這個單元裡, 讓我們逐步接觸簡單的互動式介面, 希望結束以後你能夠很順利地開始作業程式的設計。

這個單元裡漸進的設計過程如下:

  1. 清除螢幕程式
  2. 繪製邊界程式
  3. 熟悉文字介面螢幕座標程式
  4. 基本文字動畫練習程式

以上的範例程式會在下面步驟裡介紹, 基本上是簡單的 C 程式, 接下來的練習需要你設計類別來封裝一些看得到的物件, 例如 遊戲盤面, 每一個下墜的物體, ...

  1. 一個由上端墜下的物件
  2. 碰到邊界隨機更改方向的物件
  3. 鍵盤介面 <Esc>, p
  4. 入射角與反射角相等, 不斷彈跳的物件
  5. 更多鍵盤介面 ←, →, ↓, ↑, q, i, d
  6. 顯示 ascii art 數字 (顯示移動速度)
  7. 多個有碰撞彈跳的物件
  8. 上面 4, 5, 6, 7 的功能已經可以寫一個打磚塊的文字介面遊戲了

希望有了這些漸進的目標, 能夠讓你有比較好的基礎來進行俄羅斯方塊作業的設計

我們先拿上面的第 4 項當作今天的目標, 希望你能夠盡量完成。

你覺得這學期的實習和課程一直出現新的東西嗎? 一直出現沒有講過的東西嗎? 在產品生命週期很短的電子、資通訊產業裡, 接觸一年半就已經算是老鳥了, 很難跟別人說你還在學 - 沒有準備過的東西都不太會。在學習軟體設計的過程當中, 很多方法都會不斷地重複出現, 所以你接觸到新東西的時候的心態很重要, 很快地和過去學過相關的東西比較一下, 整理一下關鍵的差異在哪裡, 用最短的時間掌握需求, 分析需求, 運用過去的經驗與手邊的工具達成需求, 是你必須不斷不斷練習去掌握的, 如果你是用應付考試的心態在學東西, 可以用就好了, 考過的不會再考了... 那麼下一次出現的時候, 尤其是外表有一點點變化的時候, 你就會覺得又是全新的東西了, 又是完全不會了, 你在合作團隊裡的價值就不會太高。

首先介紹簡單的互動介面

到目前為止我們練習的都是文字介面, 先前我們用 cout 時, 命令列視窗 (cmd) 裡的文字會自動往上捲動, 你的程式印出 24 列之後, 列印第 25 列時, 第一列就不見了, 第二列變成第一列, 第三列變成第二列, ...所有顯示的資料往上捲動, 螢幕上面同一時間最多只能顯示 24 列, 這個特性在設計遊戲的輸出時有點討厭, 因為很多遊戲的元素顯示的位置必須要固定下來, 所以我們需要一些簡單的工具, 請下載 utilwin32.h 以及 utilwin32.cpp

這裡面運用微軟視窗系統的API設計了四個很簡單的工具:

void gotoxy(int x, int y); // 移動文字模式的游標至 (x,y) 的地方

void clrscr(); // 清除顯示畫面

WORD setTextColor(WORD color); // 改變顯示文字的顏色

// FOREGROUND_BLUE(0x01), FOREGROUND_GREEN(0x02)
// FOREGROUND_RED(0x04), FOREGROUND_INTENSITY(0x08),
// BACKGROUND_BLUE(0x10), BACKGROUND_GREEN(0x20)
// BACKGROUND_RED(0x40), BACKGROUND_INTENSITY(0x80)

void delay(int milliSecond); // 讓程式休息指定的時間

另外我們還需要 conio.h 提供的兩個鍵盤輸入的工具

int getch(); // 直接讀取鍵盤鍵入的單一字元, 不需要等候 <enter>

int kbhit(); // 檢查是否有任何按鍵

有了這六個工具, 我們就可以逐步完成上面範例程式的功能了

清除螢幕

#include "utilwin32.h" // clrscr()
#include <stdlib.h> // system()

int main()
{
    clrscr();
    system("pause");
}

你的專案裡需要加入 utilwin32.hutilwin32.cpp

範例程式

移動游標到螢幕上任意位置

運用 utilwin32 裡提供的 gotoxy(x,y) 我們可以將游標移動到座標 (x, y) 的地方, 然後再運用 cout 的 insertion operator 來輸出文字到游標所在的位置

如下圖, 螢幕上我們可以看成是 24x80 格, 游標就是那個閃動的直線、底線、或是方塊

下列程式範例在第二行第三列, 座標 (2,3), 的地方畫一個字元 z

#include "utilwin32.h" // gotoxy()
#include <iostream> // cout
#include <stdlib.h> // system()
using namespace std;

int main()
{
    gotoxy(2,3);
    cout << 'z';
    system("pause");
}

要清除座標 (2,3) 的資料的話, 也是運用 gotoxy(2,3); 然後再輸出一個空白字元
cout << ' '; 就可以

下面的函式可以繪製遊戲畫面的邊框, 其中 (orgX, orgY) 是遊戲畫面的左上角, (orgX+width-1, orgY+height-1) 是遊戲畫面的右下角, 邊框畫在這個區間的外緣

void drawBoundary(int orgX=10, orgY=5, width=25, height=16)
{
    int i;
    gotoxy(orgX-1, orgY-1);
    cout << '+';
    for (i=0; i<width; i++)
        cout << '-';
    cout << '+';

    for (i=0; i<height; i++)
    {
        gotoxy(orgX-1, orgY+i); cout << '|';
        gotoxy(orgX+width, orgY+i); cout << '|';
    }

    gotoxy(orgX-1, orgY+height);
    cout << '+';
    for (i=0; i<width; i++)
        cout << '-';
    cout << '+';
    gotoxy(1,23); // 把游標移動到視窗中固定的地方, 
}                 // 否則在畫面上會一直看到游標在不同地方閃啊閃的
範例程式 1, 2

簡易文字動畫

我們可以用簡單的文字動畫來模擬俄羅斯方塊這樣的遊戲, 最簡單的就是一個文字由畫面上方慢慢移到下方的程式, 例如

    clrscr();
    drawBoundary(orgX, orgY, width, height);

    x=orgX+width/2;
    gotoxy(x,orgY), cout << "#";
    for (y=orgY; y<orgY+height-1; y++)
    {
        delay(300); // 每次移動之間間隔 0.3 秒 (300ms)
        gotoxy(x,y), cout << " "; // 移動到下一個座標前先清除原來的文字
        gotoxy(x,y+1), cout << "#";
    }
    gotoxy(1,23);
    system("pause");
也就是每隔一小段時間 (300ms) 把原本位於位置 (x,y) 的字元清除, 在 (x, y+1) 的地方再畫一個字元出來, 像這樣單個文字的動畫, 也可以考慮在下一個座標上先畫出字元, 稍微讓兩個點重疊出現, 然後再清除前一個座標上已經畫出的字元, 例如下面的程式
    clrscr();
    drawBoundary(orgX, orgY, width, height);
    x=orgX+width/2-1;
    gotoxy(x,orgY), cout << "*";
    for (y=orgY; y<orgY+height-1; y++)
    {
        delay(200);
        gotoxy(x,y+1), cout << "*";
        delay(100);
        gotoxy(x,y), cout << " ";
    }
    gotoxy(1,23);
    system("pause");

範例程式 (畫面中左側的字元 * 是有重疊出現的, 右側的字元 # 是先刪除舊點再畫新點的)

物件化方法設計的程式

在俄羅斯方塊的遊戲裡, 可以看到好多種物件, 如果你運用物件化的方法設計程式, 程式本身的擴充能力是比較好的, 在不同的圖形化介面環境下移植是比較方便的, 接下來請運用這學期學到的語法, 設計幾個類別來完成指定的功能, 我們需要畫出一個物件由遊戲畫面的頂端慢慢掉下, 希望設計三個類別 Point, Rect, Object 如下:

Point 類別代表遊戲畫面上任意一個點, 有 x 和 y 座標, 在程式裡可以代表一個物件的位置, 也可以代表一個物件的速度, 本來像這樣由兩個整數組合的資料, 常常就用 struct 包起來就好了, 不過我們還是設計成類別, 我們希望設計一些專屬於這個類別的操作方法, 例如建構元, operator+(), 測試一個點是否在一個矩形區域裡面等等, 請實作下列介面:

  1. Point(); // 預設建構元
  2. Point(const int x, const int y); // 建構元
  3. Point operator+(const Point &rhs) const; // 計算兩個點的向量和
  4. bool isInside(const Rect &rect) const; // 判斷點是否在傳入的矩形區間內

Rect 類別可以用來表示遊戲的盤面大小, 我們也希望它可以幫忙判別任何一個點是否在盤面中, 或是任何一個物件是不是在盤面中, 有沒有超出邊界, 我們也希望這個類別有繪製的功能, 所以它可以在螢幕上繪出一個矩形的區域來作為遊戲的邊界, 最主要需要的資料成員是 左邊(left)、上邊(top)、右邊(right)、下邊(bottom) 的四個整數資料, 請注意我們希望這個矩形區間是由 left 到 right-1, 由 top 到 bottom-1, 也就是說不包含 right 那一行, 也不包括 bottom 那一列, 請實作下列介面:

  1. Rect(const int left, const int top, const int right, const int bottom); // 建構元
  2. bool contains(const Point &pt) const; // 判斷是否包含傳入的點
  3. bool contains(const Object &obj) const; // 判斷是否包含傳入的整個物件
  4. void draw() const; // 繪製矩形的邊界

請注意設計這個 Rect 類別的時候需要 Point 類別的定義, 先前設計 Point 類別的時候也需要 Rect 類別的定義, 如果在 Rect.h 裡面加入 #include "Point.h" 然後又在 Point.h 裡面加入 #include "Rect.h" 是沒有辦法成功的, 你不可能在定義 Point 類別之前先定義好 Rect 類別, 同時又在定義 Rect 類別之前先定義好 Point 類別, 這時你必須用到類別的前向宣告, 在 Point.h 中定義 Point 類別之前先加入 class Rect;, 因為在 Point 類別內只需要 Rect 的參考定義, 編譯器不需要知道完整的 Rect 類別定義, 所以可以用這種前向宣告, 在 Point.cpp 中需要 #include "Point.h" 以及 #include "Rect.h", 在定義 Rect 類別時就可以直接在 Rect.h 裡加上 #include "Point.h" 的前處理器指令了。

------------------

--- A.h ---
class B;
class A
{
public:
   int fun(B& b);
private:
    B* ptrB;
};

--- A.cpp ---
#include "A.h"
#include "B.h"

int A::fun(B& b)
{
    ptrB->fun();
    b.fun();
}

--- B.h ---
#include "A.h"
class B
{
public:
   int fun();
private:
   A a;
};

--- B.cpp ---
#include "B.h"

int B::fun()
{
    a.fun();
}


另外你需要知道下面狀況是不可能發生的
class A
{
B bObj;
};
class B
{
A aObj;
};

------------------ 還是不清楚的話請參考

Object 類別就是用來表示遊戲中落下的不同形狀物件, 這個物件主要由四個座標點組成, 如下圖我們設定其中一個點是原點, 運用 Point 類別的物件陣列記錄相對座標 (0, 0), (0,1), (1, 0), (1, -1)

另外記錄這個物件原點在螢幕上的絕對座標, 最後再記錄顯示的字元 'a', (也可以紀錄顯示的顏色), 這個類別請實作下列介面

  1. Object(const char face, const Point position, const Point data[4]); // 建構元
  2. bool move(const Point &offset, const Rect &boundary); // 移動 offset 位移
  3. bool isInside(const Rect &rect) const; // 檢查物件是否在矩形區域 rect 中
  4. void draw(bool show=true) const; // 繪製 (true), 清除 (false)

為了要有動畫的效果, move() 介面在移動之前要先呼叫 draw(false), 清除原來座標位置的資料, 也就是執行 gotoxy(...), cout << ' ' 的動作, 移動以後再呼叫 draw(true) 繪製新座標位置的資料, 也就是執行 gotoxy(...), cout << m_face 的動作, move() 介面的第二個參數是遊戲畫面的邊界, 回傳的 bool 值是希望檢查移動到目標位置以後, 整個物件是不是都還在遊戲邊界裡面, 如果超出了邊界, 就回傳 false 代表移動沒有成功, 實際上物件也不要移動到新的位置

main() 函式裡主要的邏輯如下:

int main()
{
    const int orgX = 10, orgY = 5, width = 25, height = 16;
    const Rect canvas(orgX, orgY, orgX+width-1, orgY+height-1);

    clrscr();
    canvas.drawBoundary();

    const Point shape[4] = { Point(-1,0), Point(0,0), 
                             Point(0,1), Point(0,2) };
    Object obj('*', Point(orgX+width/2-1,orgY), shape);
    assert(obj.isInside(canvas));

    obj.draw();
    while (obj.move(Point(0,1), canvas))
        delay(300);

    gotoxy(1,23); system("pause");
}

範例程式


你也可以簡單修改一下 main() 函式, 使得這個掉下來的物件在碰到邊界時, 隨機改變它移動的方向, 執行範例如下

範例程式 (這個程式沒有鍵盤介面, 所以要停下來只能用 Ctrl-C 了, 有點暴力的方法)


或是碰到邊界時用剛體反彈來改變它移動的方向, 執行範例如下

範例程式 (這個程式有鍵盤介面, 請參考下一個步驟的說明)

怎麼和程式互動? 如何由鍵盤輸入?

俄羅斯方塊的遊戲裡, 玩遊戲的人當然要可以用鍵盤操作, 例如旋轉, 左移, 右移, 直接掉下等等, 但是我們前面幾個程式裡主要是個迴圈, 不斷地在繪製文字動畫, 如果用 cin >> xyz, 那麼在等使用者輸入的時候, 文字動畫就暫停下來了, 這顯然不是我們要的效果, 要怎樣能夠得到需要的效果呢? 關鍵就在 kbhit() 這一個函式, 這是一個所謂 non-blocking 的輸入函式, 你的程式呼叫這個函式時, 如果玩遊戲的人在程式執行到這一列時有按鍵盤, kbhit() 函式就會回傳 1, 如果沒有按鍵, 程式也不會卡在這一列等候使用者輸入, 而會回傳 0, 當回傳 1 時究竟是按了哪一個按鍵呢? 請呼叫 getch() 來得到那個按下的按鍵的 ASCII 字元碼, 請測試下列程式:
#include <conio.h> // kbhit(), getch()
#include <iostream> // cout, endl
#include <iomanip>  // setw(), hex
using namespace std;
#include "utilwin32.h" // delay()
int main()
{
    int c=-1, d=-1;
    while (true)
    {
        if (kbhit()) 
        {
            c = getch();
            cout << setw(2) << hex << c << endl;
        }
        // 繪製動畫
        delay(100); // 使用者運用鍵盤輸入的速度很慢, 延遲 0.1 秒使用者不會發現
    }
    return 0;
}

如此既可以繪製動畫, 又可以在使用者按下按鍵時迅速處理; 這樣的程式架構裡面的無窮迴圈我們常常稱為訊息迴圈, 在每一個圖形化的介面中都會有這樣的迴圈, 如果要處理鍵盤右側數字方向鍵盤的話, 需要使用兩次的 getch() 如下

#include <conio.h>  // getch()
#include <iostream> // cout, endl
#include <iomanip>  // setw(), hex
using namespace std;
int main()
{
    int c=-1, d=-1;
    if ((c=getch()) == 0xe0 || c == 0) // 數字鍵或是←→↓↑方向按鍵 
        d = getch();                   // 按下時會得到 0xe0 或是 0x00
    cout << "c=" << setw(2) << hex << c 
<< " d=" << setw(2) << hex << d // 第二次 getch() 可以
<< endl; // 得到實際按鍵的掃描碼 return 0; }

範例程式 6, 7

請思考一下暫停的邏輯該如何實作!

考試時間: 180分鐘 (12:05 上傳時間截止)

  1. 請運用 Visual Studio 製作一個新的方案
  2. 完成上面的 Point, Rect, Object 類別以及鍵盤的互動
  3. 測試程式功能
將所完成的 project (只需保留 .cpp, .h, .sln 以及 .vcxproj 檔案即可; 刪除掉 .suo, .sdf, .filters, .users, debug\ 資料匣, 以及 ipch\ 資料匣下的所有內容) 以 zip/rar/7zip 程式將整個資料匣壓縮起來, 在「考試作業」繳交區選擇 Labtest2上傳

如果因為時間有限, 你沒有把程式都寫完, 也沒有太發揮創意的話, 回家以後還是可以盡量去完成程式的功能, 有遇到問題隨時可以討論, 把完成的成品 email 給我, 這樣子才能真的從這裡面得到一些經驗

進大學快要兩年了, 眼看著就要畢業了, 看到上面這樣的東西, 一定有不熟悉的感覺, 有這個感覺的時候你會有一種又學到新東西了的想法? 還是有一種又超過範圍了的感覺呢? 國中高中的時候覺得老師就是敵人, 老師最好一開始就把界線劃清楚, 規定好只要走多遠就過了, 不小心被敵人誘騙多讀一點東西, 多走一步就是不得了的損失...還陷在這種邏輯裡嗎?

記得我們在這學期第一節課時讓你看到的軟體學習曲線

在學校裡你不透過各個課程, 藉由老師同學助教攜手合力儘快脫離 desert of despair, 儘快建立和競爭者區別的門檻, 難道期待進到競爭的業界裡會出現善心人士來教你什麼是什麼嗎? 還是學校裡只要拿到好成績, 以後老闆看到你有好成績就會讓你領高薪不用做事呢?

後續練習

  • 更多鍵盤介面 ←, →, ↓, ↑, q, i, d: 要處理的按鍵越來越多以後, 鍵盤介面應該要定義 enum 的常數來代表, 同時也應該要寫成一個函式或是抽象化為一個類別
  • 顯示 ascii art 數字 (顯示移動速度): 如果是俄羅斯方塊遊戲的話, 應該可以用比較醒目的大字來顯示遊戲者的成績
  • 多個有碰撞彈跳的物件: 和牆壁碰撞以及和其它物體碰撞的邏輯基本上是一樣的, 但是因為物體互相之間檢查是否碰撞, 會遇見 A 檢查 B, B 又檢查 A 的狀況, 需要比較小心處理 (其實有彈跳的這個功能的話應該要寫成打磚塊的遊戲的, 範例執行程式)
  • 接下來應該可以實作俄羅斯方塊中多種物件以及旋轉的邏輯, 還有需要的鍵盤介面
  • 俄羅斯方塊中計算分數以及在下方填滿一列就消去的邏輯也需要另外設計 (可以用一個二維的陣列來製作, 和上面打磚塊遊戲裡面的那一堆磚塊功能很像)
  • 如果已經完成初步的文字介面俄羅斯方塊遊戲, 接下來可以嘗試設計運用 WinBGIm 圖形介面的俄羅斯方塊遊戲, 基本的邏輯都沒有改變, 鍵盤介面也沒有什麼變化,只是在 draw() 介面中需要用 WinBGIm 的 bar() 函式來完成, 顯示文字用 outtextxy() 函式來完成, 請參考基本的練習

C++ 物件導向程式設計課程 首頁

製作日期: 04/21/2016 by 丁培毅 (Pei-yih Ting)
E-mail: pyting@mail.ntou.edu.tw TEL: 02 24622192x6615
海洋大學 電機資訊學院 資訊工程學系 Lagoon