2016
09
23

HTML5 Canvas 不需要繪圖的插件就能直接在網頁上畫圖![轉載於firefox謀智台客]

關鍵字:CSS 轉場文件source-atopdestination-outHTML5 Canvas

我就先不賣關子,底下這個 fiddle 就是這篇文章的刮刮樂範例完整版。
可以玩玩看,試著瞭解裡面的程式腳本,或是 fork 來改改看。
有興趣但看不太懂 code 的話, 就跟著本篇文章的介紹帶你入門吧!

如果大家有看過九月份的「Firefox OS 讓你儘情享受每一刻」活動網頁(註 1),

應該很好奇一開始的刮刮樂是如何做到的吧?
在研究該如何實作這個功能的時候,因為刮刮樂和繪圖的概念很類似,
所以第一直覺就是想到要用 HTML5 Canvas。

但 Canvas 如此博大精深,究竟要從何做起呢?讓我們繼續看下去……

瞭解 Canvas 的合成參數設定

Canvas 的 globalCompositeOperation 屬性設定可以打破原本圖形只能依繪製順序往上覆蓋的限制,它可以將圖形畫在另一圖形之下,也可用來遮蔽、清除圖形區域。讓圖形的繪製組合更有彈性。在 MDN 的 Canvas 合成效果教學文件(註 2)裡可以看到各種不同合成參數的執行效果:

canvas 合成參數圖例

(注意其中 darker 已從 canvas 標準規範中移除並不再支援)

圖例中藍色是設定合成參數之前繪製上去的,也就是 destination;
紅色則是設定合成參數之後繪製上去的,也就是 source。

哇靠 12 種也太多了吧!簡直是大海撈針!
但冷靜下來仔細想想,其實刮刮樂不就和小畫家的橡皮擦功能差不多嗎?
如果藍色方塊是我想要擦掉的圖形,
而紅色的圓圈是橡皮擦工具,刮完後剩下的不就是一個缺圓角的方塊了嗎?
因此答案很明顯了:登登登- destiniation-out 就是你啦!

用滑鼠刮掉圖案

要開始寫一個功能時,第一件要做的事絕對不是建立一個空白的 javascript 檔案,重新發明輪子,
網路上充滿了熱心的神人,你想得到的功能幾乎早就有人做出來並公開原始碼。
我們只需要想出對的關鍵字,去把它找出來就行了。
於是我在谷歌輸入「javascript scratcher」,運氣很不錯,
第一個搜尋結果(註 3)就是我需要的範本。

但仔細研究以後發現三個問題:

  1. 我想讓刮刮樂好刮一點,讓滑鼠經過之後就開始刮,而不需要點下滑鼠左鍵。
  2. 我的刮刮樂圖形是圓的,且有透明背景,套用在上面時透明部分會刮出不必要的顏色來。
  3. 刮完後我想用 CSS 在底圖上加上簡單動畫特效,底圖若載入 canvas 內就無法做到。

於是我做了一些修改:

function mousemove_handler(e) {
if (!this.mouseDown) {
return true;
}
var local = getLocalCoords(c, getEventCoords(e));
- this.scratchLine(local.x, local.y, false);
- this.recompositeCanvases();
+ this.scratchLine(local.x, local.y);
return false;
};
- $(c).on('mousedown', mousedown_handler.bind(this))
- .on('touchstart', mousedown_handler.bind(this));
$(document).on('mousemove', mousemove_handler.bind(this));
$(document).on('touchmove', mousemove_handler.bind(this));
-
- $(document).on('mouseup', mouseup_handler.bind(this));
- $(document).on('touchend', mouseup_handler.bind(this));

首先我把程式裡處理 mouseup/mousedown 的部分刪去,只留下 mousemove 的部分,
用來判斷是否按下滑鼠的 flag 則一律設定成 true,如此一來就可以直接刮不需要按滑鼠左鍵。

@@ -78,20 +78,20 @@
- function Scratcher(canvasId, backImage, frontImage) {
+ function Scratcher(canvasId, backImage) {
this.canvas = {
- 'main': $('#' + canvasId).get(0),
- 'temp': null,
- 'draw': null
+ 'main': $('#' + canvasId).get(0)
};
- this.mouseDown = false;
+ this.fresh = true;
+
+ this.mouseDown = true;
this.canvasId = canvasId;
this._setupCanvases(); // finish setup from constructor now
- this.setImages(backImage, frontImage);
+ this.setImages(backImage);
this._eventListeners = {};
};
@@ -99,13 +99,12 @@
- Scratcher.prototype.setImages = function (backImage, frontImage) {
+ Scratcher.prototype.setImages = function (backImage) {
this.image = {
- 'back': { 'url': backImage, 'img': null },
- 'front': { 'url': frontImage, 'img': null }
+ 'back': { 'url': backImage, 'img': null }
};
- if (backImage && frontImage) {
+ if (backImage) {
this._loadImages(); // start image loading from constructor now
}

接下來我把原本初始化時,傳進的前後景兩張圖的部分,改成只剩下刮刮樂的前景,
讓 canvas 只用在處理刮除的前景部分,而背景則放在 canvas 外的另一個元素中。

@@ -172,24 +171,11 @@
Scratcher.prototype.recompositeCanvases = function () {
- var tempctx = this.canvas.temp.getContext('2d');
- var mainctx = this.canvas.main.getContext('2d');
-
- // Step 1: clear the temp
- this.canvas.temp.width = this.canvas.temp.width; // resizing clears
-
- // Step 2: stamp the draw on the temp (source-over)
- tempctx.drawImage(this.canvas.draw, 0, 0);
-
- // Step 3: stamp the background on the temp (!! source-atop mode !!)
- tempctx.globalCompositeOperation = 'source-atop';
- tempctx.drawImage(this.image.back.img, 0, 0);
-
- // Step 4: stamp the foreground on the display canvas (source-over)
- mainctx.drawImage(this.image.front.img, 0, 0);
-
- // Step 5: stamp the temp on the display canvas (source-over)
- mainctx.drawImage(this.canvas.temp, 0, 0);
+ var can = this.canvas.main;
+ var ctx = can.getContext('2d');
+ ctx.globalCompositeOperation = 'copy';
+ ctx.drawImage(this.image.back.img, 0, 0);
+ ctx.globalCompositeOperation = 'destination-out';
};
 
@@ -200,14 +186,15 @@
- Scratcher.prototype.scratchLine = function (x, y, fresh) {
- var can = this.canvas.draw;
+ Scratcher.prototype.scratchLine = function (x, y) {
+ var can = this.canvas.main;
var ctx = can.getContext('2d');
ctx.lineWidth = 30;
ctx.lineCap = ctx.lineJoin = 'round';
- ctx.strokeStyle = '#f00'; // can be any opaque color
- if (fresh) {
+ ctx.strokeStyle = 'rgba(0,0,0,1)'; // can be any opaque color
+ if (this.fresh) {
ctx.beginPath();
+ this.fresh = false;
// this +0.01 hackishly causes Linux Chrome to draw a
// "zero"-length line (a single point), otherwise it doesn't
// draw when the mouse is clicked but not moved:

最後因為原腳本中兩張圖時用的合成模式是 source-atop ,
這部分改成前面提到的 destination-out。並把原本處理兩張圖合成的部分刪去。
如此一來就可以做出刮刮樂(橡皮擦)的效果。

有了這段刮刮樂腳本後,只要像 fiddle 中那樣把 HTML 元素和其 CSS 寫好,
再加入這段程式腳本,你就有一個基本的網頁刮刮樂了!

上面的腳本中 "fx-scratcher" 是 canvas 元素的 id 。你也可以換成自訂的 id。
而 scratcherImage 是刮刮樂圖片(灰色圓形圖)的檔案路徑。
在前面的 fiddle 中因為 canvas 載入的外部圖片會因為安全性限制,
而無法進行我們下面要接著介紹的「取得刮除進度」。
所以我改成用 data url 的方式嵌入圖片,若在本機用自己的圖測試則用檔案路徑即可。

以上只點出修改的重點,完整的變動差異比較可以在這個 Gist Revisions 裡看到。

取得刮除進度

 

這個函式是原本的範本程式裡就寫好的,用途是取得目前刮剩下的部分比例。
我只把原本前面的 canvas.draw 改成 canvas.main ,
因為前面修改過後我們只需要一個 canvas 即可。

這段程式腳本的重點是透過 ctx.getImageData 將圖片轉為陣列資料,
再用迴圈去檢查陣列內還留有顏色的比例。
stride 參數是用來設定檢查的頻率,數字愈大則檢查的頻率愈低,
避免每次都要掃過所有資料影響執行效能。
若 pdata[i] = 0 代表已被刮除。

腳本中的函式最後回傳的是一個介於 1 ~ 0 之間的小數,
代表的意義是「未刮除部分所佔比例」。

瞭解 fullAmount 的運作方式和回傳值意義之後,
接著只要在 scratch 事件發生時去檢查 fullAmount 的回傳值,
即可依據目前的刮除進度做出對應的效果。

上面這段腳本是判斷剩餘未刮除的部分小於 3% 時就當作刮除完成,
並在刮刮樂元素上加上 "complete" 的 class ,
接下來的「開獎」效果只要透過 CSS 來處理即可。

One more thing – 做個假掰的開獎效果

我要做的開獎效果分成兩個部分,先是讓剩下沒刮完的部分淡出消失,
再來就是讓 Firefox 的圖示放大淡出,以做出開啟 App 的效果。

要用 CSS 達成這些效果就要靠 CSS 的轉場功能 - 也就是 transition 啦!
這裡腳本中使用的 transition 屬性是四個轉場屬性的簡易表示式,

這行裡指定的屬性依序為轉場套用屬性 (transition-property)、轉場持續時間 (transition-duration)、
轉場速率變化曲線函式 (transition-timing-function)、轉場延遲時間 (transition-delay)。
要注意的是 transition-property 若在不需要太多屬性同時進行轉場的狀況下,
建議明確列出所有需要轉場的屬性(而不要使用 all),以防執行效能被拖慢。
詳細的說明可參考 MDN 的 CSS 轉場文件(註 4)。

寫好轉場效果的設定後,接下來就是在 .scratcher.complete CSS 選擇器底下,
指定各別元素的樣式變化。只需要簡單幾行 CSS 就能達成。
完成之後,只要當刮除進度超過 97 %,並執行到這行腳本時:

CSS 就會偵測到 class 的改變,並開始執行漸變轉場效果。
是不是很有趣呢?你也來試著動手做做看吧!

傑立資訊傑立資訊事業有限公司

電話:(02)2739-9096 | 傳真:(02)2739-6637 | 客服:[email protected] | 臺北市信義區和平東路3段257號6樓map

© 2019 傑立資訊 All rights reserved.