遊戲ui設計2

 
使用C++和Directx開發GUI(二)
發表日期:2006-09-03作者:[轉貼] 出處:  


歡迎您繼續閱讀"使用C++和Directx開發GUI"的第二部分.這裏是第一部分.接着我們的主題(講解在我未來的遊戲如何使用GUI(圖形用戶界面)),本文將解釋窗體的許多神祕之處.我們將關注窗體樹如何工作,爲我們使用GUI制訂計劃,以及創建窗體類的細節,包括繪製,消息機制,座標系統和其他所有的麻煩事兒. 在此我們將着重使用C++.如果你對純虛函數,dynamic_cast'ing等等已經生疏了,那麼趕快翻翻C++書再繼續吧.

不開玩笑了,讓我們開始.

在涉及代碼之前,明確我們的目標是很重要的.

在我們的遊戲已完成的GUI裏,我們將使用一個樹來跟蹤顯示在屏幕上的每個窗體.窗體樹是個簡單的N節點樹.樹的根部是視窗桌面(windows desktop).桌面窗體(Desktop window)的子窗體通常是應用程序的主窗體;主窗體的子窗體是對話框,對話框的子窗體是獨立的對話控件(按鈕,文本框等).重要的區別在於--窗體的外觀並不取決於它在樹中的位置.例如,許多遊戲把按鈕直接放在他們的桌面窗體上,就如同對話框一樣. 是的,按鈕也是窗體.意識到這一點是很重要的.一個按鈕只是一個有着有趣外觀的窗體.實際上,所有的GUI控件都是有着不同外觀的簡單窗體.這體現了C++的能力.如果我們創建一個繼承的窗體類,給它幾條虛函數,我們就能通過重載基類的函數輕易地創建我們的控件.如此應用多態性簡直稱得上優雅;實際上,許多C++書將它作爲範例(在第三部分我將詳述此點). 這是我們的基本設計,下面讓我們想想應用方法.

計劃

當我應用我的GUI時,我做了如下幾步:

1.首先我寫了些基本的窗體管理代碼.這些代碼負責窗體樹,增加/刪除窗體,顯示/隱藏窗體,把它們移動到Z座標的頂端(即在最前顯示),等等.我通過在窗體應處的位置繪製矩形完成了窗體的繪製過程,然後根據窗體的Z座標在左上角繪製一個數字. 如果你購買或編寫一個優秀可靠的指針陣列的模版類,那你的生活將會變得非常輕鬆.STL(標準模版庫Standard Template Library)得到許多C++版本的支持,它有很多好的模板性的指針陣列類,但是如果你想使用你自己的模板類,在你應用於你的窗體管理之前要進行完整徹底的測試.現在你要注意的問題是由錯誤的陣列類所引起的不易察覺的內存泄漏或空指針引用.

2.一旦我有了基礎的窗體管理函數,我花了一些時間思考我的座標系統.寫了一些座標管理函數.

3.下一步,我處理窗體繪製代碼.我繼承一個"奇異窗體"類,並顯示它如何使用一套九個精靈程序繪製自身的--其中四個精靈程序繪製角落,四個繪邊,一個繪製背景. 使用這九個窗體精靈程序,使創建既有獨特的藝術外觀又可動態改變大小(ala StarDock's WindowBlinds)的窗體成爲可能.這樣做的基礎是你需要有一個相當智能的繪圖庫,一個能處理封存精靈程序,彈性精靈程序以及集中精靈程序的庫,並且它是一個非常複雜的窗體生成程序(一些藝術家可以用以創建他們的窗體的代碼),這使這種方法可以實際的實現.當然,你也要注意窗體繪製速度.

4.一旦普通窗體的繪製代碼完成,我開始實現控制部分.代碼控制是簡單的,但還是需要非常徹底的測試.我由簡單的控制:靜態,圖標等開始像在前面解釋的那樣來回反覆我的工作.

5.最後,完成我的控制部分後,我開始編寫一個簡單的資源編輯器,一個允許用戶可視的放置控件,佈局對話框的程序.這個資源編輯器用了我整整一個月的時間,但我強烈建議這樣做(而不是用文本文件去決定位置)--圖形化對話框的建立非常容易,並且這也是一個好的練習:在完善中我在我的控制部分的代碼中沒有發現幾個bug,在實際的程序中被證明是很難解決的.

我被編寫一個可以轉換MSVC++的資源(.RC)文件爲我的GUI可使用的資源文件的程序的這個想法困擾了好久.最後,我發現這樣一個程序遠比它的價值麻煩.我寫這個GUI的目的就是要擺脫Windows的限制,爲了正真的做到這一點,我要由自己的編輯器,使用我自己的資源文件格式,按自己的形式做事情.我決定用MFC由底層實現一個所見即所得(WYSIWYG)的資源編輯器.我的需求,我決定;你的需求也許不同.如果某人想要寫一個轉化器,我將很樂於聽到這樣的消息. 現在到哪了?這篇文章剩下的部分將探究開始的兩步.這一系列的第三部分將進入令人麻木的控制代碼細節.第四部分將討論一點資源編輯器的實現和序列化窗體. 因此...讓我們來開始第一步:基本的窗體管理代碼.

實現
 
我們開始.這是爲我們基本窗體類定義的好的開始:

class gui_window
{
public:
    gui_window(); // boring

    ~gui_window(); // boring

    virtual void init(void); // boring

    gui_window *getparent(void) { return(m_pParent); }

/////////////

// section I: window management controls

/////////////


    int addwindow(gui_window *w);
    int removewindow(gui_window *w);

    void show(void) { m_bIsShown = true; }
    void hide(void) { m_bIsShown = false; }
    bool isshown(void) { return(m_bIsShown); }
    void bringtotop(void);
    bool isactive(void);

/////////////

// Section II: coordinates

/////////////


    void setpos(coord x1, coord y1); // boring

    void setsize(coord width, coord height); // boring


    void screentoclient(coord &x, coord &y);

    int virtxtopixels(coord virtx); // convert GUI units to actual pixels

    int virtytopixels(coord virty); // ditto


    virtual gui_window *findchildatcoord(coord x, coord y, int flags = 0);

/////////////

// Section III: Drawing Code

/////////////


    // renders this window + all children recursively

    int renderall(coord x, coord y, int drawme = 1);

    gui_wincolor &getcurrentcolorset(void)
    { return(isactive() ? m_activecolors : m_inactivecolors); }

/////////////

// Messaging stuff to be discussed in later Parts

/////////////


    int calcall(void);

    virtual int wm_paint(coord x, coord y);
    virtual int wm_rendermouse(coord x, coord y);
    virtual int wm_lbuttondown(coord x, coord y);
    virtual int wm_lbuttonup(coord x, coord y);
    virtual int wm_ldrag(coord x, coord y);
    virtual int wm_lclick(coord x, coord y);
    virtual int wm_keydown(int key);
    virtual int wm_command(gui_window *win, int cmd, int param) { return(0); };
    virtual int wm_cansize(coord x, coord y);
    virtual int wm_size(coord x, coord y, int cansize);
    virtual int wm_sizechanged(void) { return(0); }
    virtual int wm_update(int msdelta) { return(0); }

protected:

    virtual void copy(gui_window &r); // deep copies one window to another


    gui_window *m_pParent;
    uti_pointerarray m_subwins;
    uti_rectangle m_position;

    // active and inactive colorsets

    gui_wincolor m_activecolor;
    gui_wincolor m_inactivecolor;

    // window caption

    uti_string m_caption;
};

當你細讀我們討論的函數,你將會發現遞歸到處可見.比如,我們的程序將通過調用一個源窗體的方法renderall()來繪製整個GUI系統,這個方法又將回調它的子窗體的renderall()方法,這些子窗體的renderall()方法還要調它們的子窗體的renderall()方法,以此類推.大部分的函數都遵循這種遞歸模式. 整個GUI系統有一個全局的靜態變量--源窗體.出於安全性的考慮,我把它封裝在一個全局的函數GetDesktop()中.
現在,我們開始,我們來完成一些函數,由窗體管理代碼開始,如何?


窗體管理

/****************************************************************************
addwindow: adds a window to this window's subwin array
****************************************************************************/

int gui_window::addwindow(gui_window *w)
{
    if (!w) return(-1);
    // only add it if it isn't already in our window list.

    if (m_subwins.find(w) == -1) m_subwins.add(w);
    w->setparent(this);
    return(0);
}

/****************************************************************************
removewindow: removes a window from this window's subwin array
****************************************************************************/

int gui_window::removewindow(gui_window *w)
{
    w->setparent(NULL);
    return(m_subwins.findandremove(w));
}

/****************************************************************************
bringtotop: bring this window to the top of the z-order. the top of the
z-order is the HIGHEST index in the subwin array.
****************************************************************************/

void gui_window::bringtotop(void)
{
    if (m_parent) {
        // we gotta save the old parent so we know who to add back to

        gui_window *p = m_parent;
        p->removewindow(this);
        p->addwindow(this);
    }
}
/****************************************************************************

isactive: returns true if this window is the active one (the one with input focus).
****************************************************************************/

bool gui_window::isactive(void)
{
    if (!m_parent) return(1);
    if (!m_parent->isactive()) return(0);
    return(this == m_parent->m_subwins.getat(m_parent->m_subwins.getsize()-1));
}

這一系列函數是處理我所說的窗體管理:新建窗體,刪除窗體,顯示/隱藏窗體,改變它們Z座標.所有的這些都是完全的列陣操作:在這裏你的列陣類得到測試. 在增加/刪除窗體函數中唯一感興趣的問題是:"誰來對窗體指針負責?"在C++中,這總是一個問自己得很好的問題.Addwindow和removewindow都要獲得窗體類的指針.這就意味這創建一個新的窗體你的代碼新建一個指針並通過addwindow()把指針傳到父(桌面)窗體.那麼,誰來負責刪除你新建的指針呢?

我的回答是"GUI不擁有窗體指針;遊戲本身負責增加指針".這與C++的笨拙規則"誰創建誰刪除"是一致的.

我選擇的可行的方法是"父窗體爲它的所有子窗體指針負責".這就意味着爲了防治內存泄漏,每個窗體必須在它的(虛擬)析構函數(記住,有繼承類)中搜尋它的子窗體列陣並且刪除所有的包括在其中的窗體.

如果你決定實現一個擁有指針系統的GUI,注意一個重要的原則--所有的窗體必須動態的分配.這樣的系統崩潰最快的方法是把一個變量的地址傳到堆棧中,如調用"addwindow(&mywindow)",其中mywindow被定義爲堆棧中的局部變量.系統將好好工作直到mywindow超出它的有效區,或其父窗體的析構函數被調用,此時系統將試圖刪除給地址,這樣系統即崩潰.所以說"對待指針一定要特別的小心".

這就是爲什麼我的GUI不擁有窗體指針的主要原因.如果你在你的GUI中處理大量複雜的窗體指針(也就是說,比如你要處理屬性表),你將更想要這樣一個系統,它不必跟蹤每一個指針比且刪除只意味着"這個指針現在爲我所控制:只從你的列陣中移走它但並不刪除它".這樣只要你能保證在指針超出有效區前removewindow(),你也可以使用(小心)在堆棧中的局部變量地址.

繼續?顯示和隱藏窗體通過一個布爾型變量來完成.Showwindow()和hindewindow()只是簡單的設置或清除這個變量:窗體繪製程序和消息處理程序在它們處理任何之前先檢查這個"窗體可見"標誌位.非常簡單吧!

Z座標順序也是相當的簡單.不熟悉這種說法,可把z座標順序比爲窗體"堆棧"一個重疊一個.一開始,你也許想像DirectDraw處理覆蓋那樣實現z座標順序,你也許決定給每個窗體一個整數來描述它在z座標的絕對位置,也就是說,可能0表示屏幕的頂端,則-1000代表最後.我想了一下這種Z座標順序實現方法,但我不贊成--Z座標絕對位置不是我所關心的;我更關心的是他們的相對位置.也就是說,我不需要準確的知道一個窗體在另一個的多後,我只要簡單的知道這個給定的窗體在另一個的後面還是前面.

所以,我決定實現Z座標順序如下:在列陣中有最大的索引值,m_subwins,的窗體在"最前".擁有[size-1]的窗體緊跟其後,緊接着是[size-2],依次類推.位置爲[0]的窗體將在最底.用這種方法Z座標順序實現變得非常容易.而且,一舉兩得,我將把最前的窗體視爲活動窗體,或更技術的說法,它將被視爲擁有輸入焦點的窗體.儘管我的GUI使用的這種"始終最前"窗體是有限制的(比如,在Windows NT中的任務管理器不管輸入焦點始終在所有的窗體之前),我覺得這樣有利於使代碼儘可能的簡單.

當然,我用數列表示Z座標順序在我移動窗體到最前時處理數列付出了一些小的代價.比如,我要在50個窗體中將第二個窗體移到最前;我將爲了移動二號窗體而移動48個窗體.但信運的是,移動窗體到Z座標最前不是最耗時的函數,即使是,也有很多好的快的方法可以處理,比如鏈表即可.

看看我在bringtotop()函數中的小技巧.因爲我知道窗體不擁有指針,我就刪除這個窗體又馬上創建一個,非常有效率的將它重定位在數列最前.我這樣做是因爲我的指針類,uti_pointerarray,已經被編寫好了一旦刪除一個元素,所有的更高的元素將向後移動.

這就是窗體管理了.現在,進入有趣的座標系統?

座標系統

/****************************************************************************
virtual coordinate system to graphics card resolution converters
****************************************************************************/

const double GUI_SCALEX = 10000.0;
const double GUI_SCALEY = 10000.0;

int gui_window::virtxtopixels(int virtx)
{
    int width = (m_parent) ? m_parent->getpos().getwidth() : getscreendims().getwidth();
    return((int)((double)virtx*(double)width/GUI_SCALEX));
}

int gui_window::virtytopixels(int virty)
{
    int height = (m_parent) ? m_parent->getpos().getheight() : getscreendims().getheight();
    return((int)((double)virty*(double)height/GUI_SCALEY));
}

/****************************************************************************
findchildatcoord: returns the top-most child window at coord (x,y);
recursive.
****************************************************************************/

gui_window *gui_window::findchildatcoord(coord x, coord y, int flags)
{
    for (int q = m_subwins.getsize()-1; q >= 0; q--)
    {
        gui_window *ww = (gui_window *)m_subwins.getat(q);
        if (ww)
        {
            gui_window *found = ww->findchildatcoord(x-m_position.getx1(), y-m_position.gety1(), flags);
            if (found) return(found);
        }
    }

    // check to see if this window itself is at the coord - this breaks the recursion

    if (!getinvisible() && m_position.ispointin(x,y))
        return(this);
    return(NULL);
}

我的GUI最大的優勢是獨立的解決方案,我稱之爲"彈性對話框".基本上,我希望我的窗體和對話框根據它們運行系統的屏幕設置決定它們的大小.對系統的更高的要求是,我希望窗體,控件等在640 x 480的屏幕上擴張或縮小.同時我也希望不管它們父窗體的大小,它們都可以適合.

這就意味着我需要實現一個像微軟窗體一樣的虛擬座標系統.我以一個任意的數據定義我的虛擬座標系統--或者說,"從現在起,我將不管窗體的實際尺寸假設每一個窗體都是10000 x 10000個單元",然後我的GUI將在這套座標下工作.對於桌面,座標將對應顯示器的物理尺寸.

我通過以下四個函數實現我的想法:virtxtopixels(),virtytopixels(), pixelstovirtx(), 和pixelstovirty(). (注意:在代碼中之列出了兩個;我估計你已理解這個想法了).這些函數負責把虛擬的10000 x 10000單元座標要麼轉換爲父窗體的真實尺寸要麼轉換爲顯示器的物理座標.顯然,顯示窗體的函數將倚重它們.

函數screentoclient()負責取得屏幕的絕對位置並將它轉換爲相對的虛擬座標.相對的座標從窗體的左上角開始,這和3D空間的想法是相同的.相對座標對對話框是必不可少的.

在GUI系統中所有的座標都是相對於其他的某物的.唯一的一個例外就是桌面窗體,它的座標是絕對的.相對的方法可以保證當父窗體移動時它的子窗體也跟着移動,而且可以保證當用戶拖動對話框到不同位置時其結構是一致的.同時,因爲我們整個虛擬座標系統都是相對的,當用戶拉伸或縮小一個對話框時其中的所有控件都會隨之變化,自動的儘量適合新的尺寸.對我們這些曾在win32中試過相同特性的人來說,這是個令人驚異的特點.

最後,函數findchildatcoord()取得(虛擬)座標確定哪個(如果有)子窗體在當前座標--非常有用,比如,當鼠標單擊時,我們需要知道哪個窗體處理鼠標單擊事件.這個函數通過反向搜尋子窗體列陣(記住,最前的窗體在列真的最後面),看那個點在哪個窗體的矩形中.標誌參數提供了更多的條件去判斷點擊是否發生;比如,當我們開始實現控制時,我們會意識到不讓標示和光標控件響應單擊是有用的,取而帶之應給在它們下面的窗體一個機會響應--如果一個標示放在一個按鈕上面,即使用戶單擊標示仍表示單擊按鈕.標誌參數控制着這些特例.



現在,我們已經有了座標,我們可以開始繪製我們的窗體了?

繪製窗體

遞歸是一柄雙刃劍.它使得繪製窗體的代碼很容易跟蹤,但是它也會造成重複繪製像素,而這將嚴重的影響性能。(這就是說,例如你有一個存放50個相同大小相同位置的窗體,程序會一直跑完50個循環,每個像素都會被走上50遍)。這是個臭名昭著的問題。肯定有裁剪算法針對這種情況,實際上,這是個我需要花些時間的領域。在我自己的程序-Quaternion's GUI 在非遊戲屏幕過程(列標題和關閉等等)中一般是激活狀態的,要放在對GUI而言最精確的位置是很蠢的想法,因爲根本就沒有任何其他的動作在進行。

但是,我在對它進行修補。現在我試圖在我的繪製方法中利用DirectDrawClipper對象。到現在爲止,初始的代碼看起來很有希望。下面是它的工作方式:桌面窗口“清除”裁剪對象。然後每個窗口繪製它的子窗口,先畫頂端的,在畫底端的。當每個窗口繪製完畢後,把它的屏幕矩形加入到裁剪器,有效地從它之下的窗口中“排除”這個區域(這假設所有的窗口都是100%不透光的).這有助於確保起碼每個像素將被只繪製一次;當然,程序還是被所有的GUI渲染所需要的計算和調用搞的亂糟糟的,(並且裁剪器可能已經滿負載工作了),但是起碼程序不會繪製多餘的像素.裁剪器對象運行的快慢與否使得這是否值得還不明瞭。

我也在嘗試其他的幾個主意-也許利用3D顯卡的內建Z緩衝,或者某種複雜的矩形創建器(dirty rectangle setup).如果你有什麼意見,請告訴我;或者自己嘗試並告訴我你的發現。

我剪掉了大量的窗體繪製代碼,因爲這些代碼是這對我的情況的(它調用了我自定的精靈類).一旦你知曉你要繪製窗體的確切的屏幕維數(screen dimensions)時,實際的繪製代碼就能夠直接被利用。基本上,我的繪製代碼用了9個精靈-角落4個,邊緣4個,背景1個-並用這些精靈繪製窗體.

色彩集需要一點兒解釋.我決定每個窗口有兩套獨特的色彩集;一套當窗口激活時使用,一套不激活時使用.在繪製代碼開始之前,調用getappropriatecolorset(),這個函數根據窗口的激活狀態返回正確的色彩集.具有針對激活和非激活狀態的不同色彩的窗口是GUI設計的基本規則;它也比較容易使用.


現在我們的窗口已經畫完了,開始看看消息吧。

窗口消息

這一節是執行GUI的核心。窗口消息是當用戶執行特定操作(點擊鼠標,移動鼠標,擊鍵等等)時發送給窗口的事件.某些消息(例如WM_KEYDOWN)是發給激活窗口的,一些(WM_MOUSEMOVE)是發給鼠標移動其上的窗口,還有一些(WM_UPDATE)總是發給桌面的.

微軟的Windows有個消息隊列.我的GUI則沒有-當calcall()計算出需要給窗口送消息時,它在此停下並且發送消息-它爲窗口調用適當的WM_XXXX()虛函數.我發現這種方法對於簡單的GUI是合適的.除非你有很好的理由,不要使用一個太複雜的消息隊列,在其中存儲和使用線程獲取和發送消息.對大多說的遊戲GUI而言,它並不值得.

此外,注意WM_XXXX()都是虛函數.這將使C++的多態性爲我們服務.需要改變某些形式的窗口(或者控件,比如按鈕),處理鼠標左鍵剛剛被按下的事件?很簡單,從基類派生出一個類並重載它的wm_lbuttondown()方法.系統會在恰當的時候自動調用派生類的方法;這體現了C++的力量.

就我自己的意願,我不能太深入calcall()的細節,這個函數得到所有的輸入設備併發出消息.它做很多事,並有很多對我的GUI而言特定的行爲.例如,你或許想讓你的GUI像X-Window一樣運行,在鼠標活動範圍之內的窗口總是處於激活狀態的窗口.或者,你想要使得激活窗口成爲系統模態窗口(指不可能發生其他的事直到用戶關閉它),就像許多基於蘋果平臺(Mac)的程序那樣.你會想要在窗口內的任何位置點擊來關閉窗口,而不是僅僅在標題欄,像winamp那樣.calcall()的執行結果根據你想要GUI完成什麼樣的功能而會有很大的不同.

我會給你一個提示,雖然-calcall()函數不是沒有狀態的,實際上,你的calcall()函數可能會變成一個很複雜的狀態機(state machine).關於這一點的例子是拖放物體.爲了恰當的計算普通的"鼠標鍵釋放"事件和相似的但完全不同的"用戶在拖動的物體剛剛放下"事件之間的不同,calcall()必須有一個狀態參數.如果你對有限狀態機已經生疏了,那麼在你執行calcall()之前複習複習將會使你不那麼頭痛.

在窗口頭文件中包括的wm_xxxx()函數是我感覺代表了一個GUI要計算和發送的信息的最小集合.你的需要可能會不同,你也不必拘泥於微軟視窗的消息集合;如果自定的消息對你很合適,那麼就自己做一個.



窗口消息

在文章的第一部分我提到了一個叫做CApplication::RenderGUI()的函數,它是在計算之後繪製我們的GUI的主函數:

void CApplication::RenderGUI(void)
{
    // get position and button status of mouse cursor

    // calculate mouse cursor's effects on windows / send messages

    // render all windows

    // render mouse

    // flip to screen

}

最後,讓我們開始加入一些PDL(頁面描述語言).

void CApplication::RenderGUI(void)
{
    // get position and button status of mouse cursor

    m_mouse.Refresh();

    // calculate mouse cursor's effects on windows / send messages

    GetDesktop()->calcall();

    // render all windows

    GetDesktop()->renderall();

    // render mouse

    m_mouse.Render();

    // flip to screen

    GetBackBuffer()->Flip();
}

 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章