跳到主要內容

867的HTML5移植

[PLAY]

這個遊戲是較早期的作品,所以使用的技術是以當時自己較熟悉的Win32相關技術製作。遊戲核心部份一開始就己分離出來,但還不是100%跨平台。畫面部份是以Win32 GDI呈現,以及透過Windows視窗機制處理輸入。本次目標是將遊戲移植到HTML5,主要的工作有三個部份。

  1. 將遊戲核心Gameplay確實作到跨平台。
  2. 將畫圖及輸入部份抽離作到跨平台。
  3. 將遊戲移植為HTML5可以在Browser上執行。
第一部份己幾乎完成了,只需再作點小修改就能達成。主要的移植工作在於第二和第三部份,以下是這部份工作的重點記錄。

抽離繪圖層

因為867和Win32 GDI緊密結合,一時無法輕易的將繪圖層抽離出來。萬事起頭難,可以先從最簡單的改良開始,一次一小步逐步重整。首先增加一個Renderer類別,只包含一個很大略的一個render函數。而render函數的實作,是根據當前遊戲狀態透過renderTitle和renderGame二個函數實作遊戲的全部繪圖。

class Renderer
{
public:
  void render(const Game &game, CDCHandle& dc)
  {
    switch (game.iStage)
    {
    case Game::STAGE_TITLE:
      renderTitle(dc);
      break;
    case Game::STAGE_GAME:
      renderGame(dc, Game::MENU_NONE);
      break;
    case Game::STAGE_MENU:
      renderGame(dc, Game::MENU_GAME);
      break;
    case Game::STAGE_DIE:
      renderGame(dc, Game::MENU_NONE);
      break;
    case Game::STAGE_OVER:
      renderGame(dc, Game::MENU_OVER);
      break;
    case Game::STAGE_WIN:
      renderGame(dc, Game::MENU_NONE);
      break;
    }
  }
  virtual void renderTitle(CDCHandle& dc) {...}
  virtual void renderGame(CDCHandle& dc, int menu) {...}
};

Renderer的renderTitle及renderGame的內容,是整個從原來和Win32 GDI相依的程式完整的搬過來,現在先忽略它。定義Renderer後,原來在視窗裡的畫圖部份就可以以Renderer替換。

// Render game.
CClientDC dc(m_hWnd);
RECT rc = {0, 0, SCREEN_W, SCREEN_H};
CMemoryDC memdc(dc, rc);
Renderer renderer;
renderer.render(game, memdc);

注意到上面呼叫Renderer::render時,傳入的第二個參數memdc目前還是一樣跟Win32平台相關。底下繼續對Renderer::renderTitle整理,抽出幾個子函數。這些子函數的參數裡還是有許多和Win32平台相依的部份,也暫時忽略。

class Renderer
{
public:
  ...
  virtual void drawMenuStrings(CDCHandle& dc, int idsStart, int idsEnd, int xOffset, int yOffset, COLORREF clrNormal = RGB(0,0,0), COLORREF clrSelect = RGB(255,0,0)) const=0;
  virtual void drawMenuBar(CDCHandle& dc, int xOffset, int yOffset) const=0;
  virtual void drawMsgIcon(CDCHandle& dc, int offsetMsg, int w, int h, int xSrc, int ySrc) const=0;
  virtual void fillRect(CDCHandle& dc, LPCRECT lpRect, int brush) const=0;
};

繼續對Renderer::renderGame作類似整理,抽出幾個子函數。

class Renderer
{
public:
  ...
  virtual void drawAlphaRect(CDCHandle& dc, int brush, int x, int y, int w, int h, int step) const=0;
  virtual void drawFadeText(CDCHandle& dc, const char* str, int step, int x, int y) const=0;
  virtual void drawFireBall(CDCHandle& dc, int iBallImg, int xOffset, int yOffset, int shift) const=0;
  virtual void drawPlayer(CDCHandle& dc, int x, int y, int frame, int dir) const=0;
  virtual void drawPlane(CDCHandle& dc, int x, int y, int dir) const=0;
};

接著定義一個Win32Renderer繼承自Renderer,並實作上面那些純虛擬的畫圖子函數,然後在遊戲繪圖部份以Win32Renderer替換。這樣就能逐步的把平台無關和平台相關的繪圖程式碼抽離。

// Define Win32 Renderer.
class Win32Renderer : public Renderer
{
public:
  ...
  virtual void drawMenuStrings(CDCHandle& dc, int idsStart, int idsEnd, int xOffset, int yOffset, COLORREF clrNormal = RGB(0,0,0), COLORREF clrSelect = RGB(255,0,0)) const {...}
  virtual void drawMenuBar(CDCHandle& dc, int xOffset, int yOffset) const {...}
  virtual void drawMsgIcon(CDCHandle& dc, int offsetMsg, int w, int h, int xSrc, int ySrc) const {...}
  virtual void fillRect(CDCHandle& dc, LPCRECT lpRect, int brush) const {...}
};

// Render game.
...
Win32Renderer win32renderer;
win32renderer.render(game, memdc);

重複上面的步驟,將各個render子函數持續抽象化,盡可能的把更小的可能和平台有關的子函數抽出來,只保留和平台無關的部份。接下來開始逐步替換平台相依的部份,一次替換一個。首先把CDCHandle抽象化替換為void*,底下以Renderer::fillRect為例。

// Renderer base.
class Renderer
{
public:
  ...
  virtual void fillRect(void *pCtx, LPCRECT lpRect, int brush) const=0;
};

// Render game.
...
Win32Renderer win32renderer;
win32renderer.render(game, (void*)(HDC)memdc);

// Win32 renderer.
class Win32Renderer : public Renderer
{
public:
  ...
  virtual void fillRect(void *pCtx, LPCRECT lpRect, int brush) const
  {
    CDCHandle dc((HDC)pCtx);
    ...
  }
};

如上所示,把Renderer::fillRect的HDC參數替換成void*後。在遊戲繪圖呼叫render,傳入第二個平台相依的參數memdc時,作一個將dc轉型抽象為和平台無關的型別void*。而在平台相依的Win32Renderer::fillRect實作裡,再作一次將void*轉型回平台相依的HDC型別,底下其它實作照舊不變。這個動作好像多此一舉,但這個處理就能把平台相關的東西全部從平台無關的Renderer裡搬到外面,而達到跨平台的目的。

注意上面的兩個主要的操作:一、抽出平台相依的子函數及二、替換平台相依的參數,是可以交替進行的。持續以上操作,盡可能把平台相關和無關的部份以這種方式逐步抽離乾淨,直到可以很容易的作到跨平台移植。

遊戲移植到HTML5

HTML5的移植主要透過Emscripten將跨平台的程式碼編譯成WebAssembly。跨平台的程式碼包含有二部份,一個是和平台無關的可以直接移植的程式碼,也就是像上面的Renderer的部份。另一部份是和平台相依的需要作移植的程式碼,像是上面的Win32Renderer的部份。這裡要作的是定義一個EmscRenderer繼承自Renderer,並實作所有需要跨平台到HTML5的子函數。

class EmscRenderer : public _867::Renderer
{
public:

  virtual void drawAlphaRect(void *pCtx, int brush, int x, int y, int w, int h, int step) const;
  virtual void drawAlphaText(void *pCtx, const char* s, int step, int x, int y) const;
  virtual void drawFireBall(void *pCtx, int iBallImg, int xOffset, int yOffset, int shift) const;
  virtual void drawGameFinText(void *pCtx, const char *s, const sw2::IntRect &rc) const;
  virtual void drawGameScore(const _867::Game &game, void* pCtx, int x, int y) const;
  virtual void drawMenuStrings(void *pCtx, const char* str[], int xOffset, int yOffset, int clrNormal = COLOR_BLACK, int clrSelect = COLOR_RED) const;
  virtual void drawMenuBar(void *pCtx, int xOffset, int yOffset) const;
  virtual void drawMsgIcon(void *pCtx, int offsetMsg, int w, int h, int xSrc, int ySrc) const;
  virtual void drawPlayer(void *pCtx, int x, int y, int w, int h, int frame, int dir) const;
  virtual void drawPlane(void *pCtx, int x, int y, int w, int h, int dir) const;
  virtual void drawTitleBkgndText(void *pCtx, const char* s, const sw2::IntRect &rc) const;
  virtual void fillRect(void *pCtx, const sw2::IntRect &rc, int brush) const;
};
實作的細節沒什麼特別的,這裡不特別說明。底下只針對幾個需要作特別的處理,作了記錄。

轉換有Color Key的圖形為ARGB格式

早期要作簍空貼圖都是使用Color Key作法,也就是指定2D圖形裡某個顏色的RGB值作為Color Key,繪圖時只要是這張貼圖裡的像素的RGB是和Color Key相同,則這些像素就不會被繪出來。如下圖是遊戲裡使用的紙飛機的貼圖,大片的紫色色塊的紫色RGB(254,0,255)作為Color Key。



因為不想要改變原始貼圖格式及繪圖方式,所以一個簡單的作法是HTML5版本中,把原來的使用Color Key的貼圖作一個轉換,轉換成ARGB格式,這樣在HTML5使用drawImage時就自然可以作簍空貼圖。

轉換的方法很簡單,步驟如下。

  1. 將原始貼圖畫到一個memory canvas。
  2. 將這個canvas裡的所有像素取出處理,如果像素的RGB值和Color Key相同,則把這個像素的alpha清0。
  3. 以處理過的memory canvas建立一個新的貼圖img,使用這個img作為新貼圖來源。
如下所示,為轉換程式碼片斷。

<img id="imgPlane" src="plane.bmp" alt="" style="display:none;"/>
<img id="imgRole" src="role.bmp" alt="" style="display:none;"/>

<script>
function convertColorKeyedImg(id) {
  var img = document.getElementById(id);
  var canvas = document.createElement('canvas');
  canvas.width = img.width;
  canvas.height = img.height;
  var ctx = canvas.getContext('2d');
  ctx.drawImage(img, 0, 0);
  var imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  var data = imgData.data;
  var Color Key = [data[0], data[1], data[2]];
  for (var i = 0; i < data.length; i += 4) {
    if (data[i] == Color Key[0] &&
        data[i + 1] == Color Key[1] &&
        data[i + 2] == Color Key[2]) {
      data[i + 3] = 0;
    }
  }
  ctx.putImageData(imgData, 0, 0);
  img.src = canvas.toDataURL('image/png');
}

convertColorKeyedImg('imgRole');
convertColorKeyedImg('imgPlane');
</script>

畫火球

畫火球主要有兩個步驟。
  1. 產生火焰圖。
  2. 以火焰圖作底圖,畫出彈性球形。
產生火焰圖的部份這裡略過,網路上有許多產生火焰效果圖的資料。這裡說明怎麼利用事先建好的火焰圖作貼圖,畫出彈跳的球形。這裡的作法還是一樣利用一個memory canvas,步驟如下。

  1. 建立一個memory canvas。
  2. 在memory canvas上根據扁平需求畫一個紅色ellipse。
  3. 取出memory canvas所有像素,把所有紅色像素替換為火焰圖貼圖裡對應座標的像素。
  4. 將memory canvas的內容畫到指定畫面位置。
程式片斷如下。

function drawFireBall(x, y, w, h, W, H, xSrc, data, len) {
  var canvas1 = document.createElement('canvas');
  canvas1.width = w;
  canvas1.height = h;
  var ctx1 = canvas1.getContext('2d');
  ctx1.fillStyle = 'red';
  ctx1.beginPath();
  ctx1.ellipse(w/2, h/2, w/2, h/2, 0, 0, 2 * Math.PI);
  ctx1.fill();
  var imgData1 = ctx1.getImageData(0, 0, w, h);
  var data1 = imgData1.data;
  for (var i = 0; i < w; i++) {
    for (var j = 0; j < h; j++) {
      var idx1 = 4 * (i + j * w);
      if (255 == data1[idx1 + 0] && 0 == data1[idx1 + 1] && 0 == data1[idx1 + 2]) { // red pixel.
        var idx = 2 * (xSrc + i + (h - j - 1) * W);
        var c16 = getValue(data + idx, 'i16');
        var r5 = (c16 >> 11) & 0x1f;
        var g6 = (c16 >> 5) & 0x3f;
        var b5 = c16 & 0x1f;
        data1[idx1 + 0] = Math.floor(r5 * 255 / 31.0 + 0.5);
        data1[idx1 + 1] = Math.floor(g6 * 255 / 63.0 + 0.5);
        data1[idx1 + 2] = Math.floor(b5 * 255 / 31.0 + 0.5);
      }
    }
  }
  ctx1.putImageData(imgData1, 0, 0);
  ctx.drawImage(canvas1, x, y);
}

其實中間那個判斷是否為RGB(255,0,0)的if是可有可無的,因為畫ellipse時,範圖外不屬於扁平球的部份的alpha為0,不會被畫出來。只是多了這個檢查可以少作些運算。

留言

這個網誌中的熱門文章

猜數字遊戲 (電腦猜人)

前幾天午睡時突然被告知要參加公司內部的程式設計比賽,題目是用C寫一支文字模式的4位數字猜數字遊戲,由使用者來猜電腦的數字。在上星期時其實就已經有公佈了,但我沒有注意到所以是臨時加入,還好這是個簡單的題目,不用花多少時間就可以寫出來。 規則: - 這是一對一比賽,雙方各選擇一4位數字,不讓對方知道。 - 4位數字由數字0至9組成,每位數不得重複。 - 雙方輪流猜對方的數字,直到一方猜中為止。 - A方猜B方的數字後,B方根據A方的猜測回答幾A幾B。 - 一個A表示猜中一個數字且位置正確,一個B表示猜中一個數字但位置不正確。 - 當一方猜中4A0B時即表示猜中對方全部4個數字且位置正確,贏得比賽。 - 例:B的謎底是4208,底下箭頭左測是A的猜測,箭頭右測是B的回答。    1234 ==> 1A1B    5678 ==> 1A0B    2406 ==> 1A2B    ...    4208 ==> 4A0B ; 寫個程式讓玩家來猜電腦的數字不難,不過我從來沒有寫過讓電腦來猜玩家數字的版本,所以花了點時間想想怎麼寫。 研究後歸納出二個點。 1, 使用窮舉法將所有可能數字組合列出。 2, 每次猜測後根據結果排除不可能是答案的組合,重複這個動作直到猜中答案為止。 第1點只是實作問題,第2點概念也很簡單,但要過濾不是答案的組合根據的是什麼?乍看之下沒什麼頭緒,不過想通之後就非常簡單了。 它的基本原理如下:假如謎底是4561,如果猜1524則會得到1A2B。從相反的角度來看,如果謎底是1524,則猜4561時也會得到1A2B的回答。 利用這個方法,每一次猜測一個數字X後,再以這個數字當作答案,來和所有剩下來的候選答案作比對,如果得到的結果(幾A幾B)和數字X是一樣的話,就把這個數字保留下來繼續作為候選答案,否則就過把這個數字過濾掉。下一把,繼續從候選答案裡選一個出來猜,重複上面的動作,直到猜中為止。 ; C++ STL的algorithm裡有個叫作next_permutation的函數,可以用來生成排列。 #include <iostream> #include <algorithm> using namespace std; int main () {   int myints[] = {1,2,3};  ...

單人撲克牌遊戲 - 蒙地卡羅

新增一個簡單的單人撲克牌遊戲: 蒙地卡羅 ,簡單介紹一下玩法。 下載 事先排列好5x5張牌。 每次移動一張可以配對的牌,並消除這對牌。在上下、左右及斜向相隣的二張牌,只要擁有同樣數字(不計花色),即可配對。 消除二張配對的牌後,剩餘的牌以往左往上的方式補滿空隙,接著在發新牌補滿後面的空格。 重覆步驟2~3,直到沒有牌可以配對及發完所有牌為止。 結果有二種。一個是勝利,成功的消除掉所有牌。另一個是Gameover沒有牌可以再作配對。

以lex/yacc實作算式計算機

前面我們透過 手工的方式 實作了一個簡易的算式計算機,現在我們要開始使用工具來作同樣的事,比較看看手工和使用工具有什麼不同的差別。首先要介紹的就是lex&yacc。 lex & yacc lex(Lexical Analyzar)及yacc(Yet Another Compiler Compiler)是用來輔助程式設計師製作語法剖析器的程式工具。lex的工作就是幫助我們將輸入的資料文字串流分解成一個個有意義的token,而yacc的工作就是幫我們分析這些token和我們定義的規則作匹配。下圖中所表示的是使用lex及yacc的一般工作流程。 首先看到yacc會讀入一個.y檔案,這裡.y檔案的內容就是我們使用類似(E)BNF語法定義的語法規則,yacc會分析這些語法規則後,幫我們產生可以用來解析這些規則的程式碼,而這個檔案一般名稱預設為y.tab.c,產生的程式碼裡面最重要的一個的函式叫作yyparse。 同yacc類似,lex也會讀入一個.l的檔案,這個檔案裡面定義的是如何從文字流裡解出token的規則,使用的方法是常規表示式(regular expression)。在圖的左側中間我們還可以看到有一個叫作y.tab.h的檔案從yacc產生出來並餵給lex作輸入,這個檔案是yacc根據在讀入的.y檔裡面所定義的token代號所產生出來的一個header,這樣yacc及lex產生出來的程式碼裡面就可以使用共通定義的代碼而不必各寫個的。lex分析過.l檔案後也會產生一個一般預設叫作lex.yy.c的原始碼檔案,裡頭最重要的一個函式叫作yylex。 最後,我們把yacc產生出來的y.tab.c還有lex產生出來的lex.yy.c,以及其它我們自己撰寫的原始碼檔案一起拿來編譯再作連結,最後產生出來的就是一個可以用來解析我們定義的語法的解析器工具。以上是整個lex及yacc的使用流程概觀。 常規表示式 在正式使用lex之前,我們首先來對常規表示法作一個基本的認識。常規表示法是一種用來表示字串樣式(pattern)的中繼語言,就好比前文所介紹的(E)BNF表示式一樣,都是用來描述其它語言的語言,只不過用途不太一樣罷了。 常規表示式使用一些中繼符號(meta-symbol)以及ASCII字元定義字串樣式,以下列出一些常規表示式所使用的符號。 . 表示除了換行字元...