這個遊戲是較早期的作品,所以使用的技術是以當時自己較熟悉的Win32相關技術製作。遊戲核心部份一開始就己分離出來,但還不是100%跨平台。畫面部份是以Win32 GDI呈現,以及透過Windows視窗機制處理輸入。本次目標是將遊戲移植到HTML5,主要的工作有三個部份。
第一部份己幾乎完成了,只需再作點小修改就能達成。主要的移植工作在於第二和第三部份,以下是這部份工作的重點記錄。
因為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的移植主要透過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作法,也就是指定2D圖形裡某個顏色的RGB值作為Color Key,繪圖時只要是這張貼圖裡的像素的RGB是和Color Key相同,則這些像素就不會被繪出來。如下圖是遊戲裡使用的紙飛機的貼圖,大片的紫色色塊的紫色RGB(254,0,255)作為Color Key。
因為不想要改變原始貼圖格式及繪圖方式,所以一個簡單的作法是HTML5版本中,把原來的使用Color Key的貼圖作一個轉換,轉換成ARGB格式,這樣在HTML5使用drawImage時就自然可以作簍空貼圖。
轉換的方法很簡單,步驟如下。
如下所示,為轉換程式碼片斷。
<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>
畫火球主要有兩個步驟。
產生火焰圖的部份這裡略過,網路上有許多產生火焰效果圖的資料。這裡說明怎麼利用事先建好的火焰圖作貼圖,畫出彈跳的球形。這裡的作法還是一樣利用一個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,不會被畫出來。只是多了這個檢查可以少作些運算。