867的HTML5移植

«Home / 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,不會被畫出來。只是多了這個檢查可以少作些運算。