canvas 基礎系列(三)之實現九宮格抽獎

木子七發表於2017-08-18

上一章講解了如何使用 canvas 實現大轉盤抽獎點選回顧;但有些地方並沒有講清楚,比如上一章實現的大轉盤,獎品選項只能填文字,而不能放圖片上去。
這一次,我們用 canvas 來實現九宮格抽獎(我已沉迷抽獎無法自拔~),順便將渲染圖片功能也給大家過一遍。
本章涉及到的知識點,主要有:

  1. context.drawImage() 方法渲染圖片
  2. context.isPointInPath() 方法,在畫布中製作按鈕
  3. setTimeout() 方法,來做逐幀動畫
  4. 九宮格的繪製演算法

Github 倉庫 | demo 預覽

掃描二維碼預覽demo
掃描二維碼預覽demo

專案結構:

因為本章程式碼比較繁雜,我不會全部貼出來;建議進入我的 Github 倉庫,找到 test 檔案下的 sudoku資料夾下載,本章講解的程式碼都在裡面啦。

|--- js
|--- | --- variable.js  # 包含了所有全域性變數
|--- | --- global.js    # 包含了本專案所用到的公用方法
|--- | --- index.js     # 九宮格主體邏輯程式碼
|--- index.html複製程式碼


繪製九宮格:

首先,我們需要繪製出一個九宮格,大家都知道九宮格長什麼樣子哈,簡單的排9個方塊,不就搞定了麼?

不不不,作為一名合格的搬磚工,我們需要嚴於律己,寫程式碼要抽象,要能重用,要...
假如哪天產品大大說,我要12宮格兒的,15的,20的,你咋辦,一個個重新算額~
所以,我們得做成圖1這樣的:

圖1
圖1

敲敲數字,鳥槍變大炮。不管你9宮還是12宮還是自宮,哥都不怕。

以下是我的實現方法,如果大家有更簡單的方法,請告訴我,請告訴我,請告訴我,學美術出生的我數學真的很爛~


  • 九宮格的四個頂點

我們將九宮格看做一個完整的矩形,矩形有四個頂點;
假設每一行每一列,我們只顯示3個小方塊(也就是傳統的九宮格),那麼四個頂點上的小方塊序號分別是,0, 2, 4, 6
假設每一行每一列,我們顯示4個小方塊,那麼四個頂點上的小方塊序號分別是,0, 3, 6, 9
以此類推,每行每列顯示5個小方塊,就是 0, 4, 8, 12


每行每列小方塊數量 左上角 右上角 右下角 左下角
3個 0 2 4 6
4個 0 3 6 9
5個 0 4 8 12


如圖2:

圖2
圖2

聰明的小夥伴們應該已經發現規律了,在圖1中,我們使用的神祕變數 AWARDS_ROW_LEN ,它的作用就是指定九宮格每行每列顯示多少個小方塊;

接著,我們繪製的原理是:分成四步,從每一個頂點開始繪製小方塊,直到碰到下一個頂點為止;

我們會發現,當 AWARDS_ROW_LEN = 3 時,我們從 0 ~ 1,從 2 ~ 3... ,每一次繪製兩個小方塊;

AWARDS_ROW_LEN = 4 時,我們從0 ~ 2,從 3 ~ 5,每一次繪製三個小方塊,繪製的步數剛好是 AWARDS_ROW_LEN - 1;如圖3:

圖3
圖3

所以我們得出一個變數 AWARDS_TOP_DRAW_LEN,來表示不同情況下,每個頂點繪製的步數;

我們通過 AWARDS_TOP_DRAW_LEN 這個變數,又可以推算出,任何情況下,矩形四個頂點所在的小方塊的下標:

你可以列舉多種情況,來驗證該公式的正確性

LETF_TOP_POINT =     0,
RIGHT_TOP_POINT =    AWARDS_TOP_DRAW_LEN,
RIGHT_BOTTOM_POINT = AWARDS_TOP_DRAW_LEN * 2,
LEFT_BOTTOM_POINT =  AWARDS_TOP_DRAW_LEN * 2 + AWARDS_TOP_DRAW_LEN,複製程式碼


  • 通過四個頂點,繪製九宮格

得到了每個頂點的下標,那就意味著我們知道了一個頂點距離另一個頂點之間,有多少個小方塊,那麼接下來就非常好辦了,

  1. 我們可以通過 AWARDS_TOP_DRAW_LEN 乘以4,來獲取總的獎品個數,作為迴圈條件(AWARDS_LEN);
  2. 我們可以獲取整個矩形的寬度,預設就讓它等於 canvas 的寬度(SUDOKU_SIZE);
  3. 自定義每個小方塊之間的間距(SUDOKU_ITEM_MARGIN);
  4. 通過矩形的寬度除以一排繪製的小方塊的數量,再減去小方塊之間的間距,得到每個小方塊的尺寸(SUDOKU_ITEM_SIZE)。


變數有點多·如果你感覺有點懵逼,請仔細查閱原始碼 variable.js 中的變數,搞懂每個變數的代表的意義。


我們已經拿到所有繪製的條件,接下來只需要寫個迴圈,輕鬆搞定!

function drawSudoku() {
    context.clearRect(0, 0, canvas.width, canvas.height);

    for (let i = 0; i < AWARDS_LEN; i ++) {

        // 頂點的座標
        let max_position = AWARDS_TOP_DRAW_LEN * SUDOKU_ITEM_SIZE + AWARDS_TOP_DRAW_LEN * SUDOKU_ITEM_MARGIN;

        // ----- 左上頂點
        if (i >= LETF_TOP_POINT && i < RIGHT_TOP_POINT) {
            let row = i,
                x = row * SUDOKU_ITEM_SIZE + row * SUDOKU_ITEM_MARGIN,
                y = 0;

            // 記錄每一個方塊的座標
            positions.push({x, y});

            // 繪製方塊
            drawSudokuItem(
                x, y, SUDOKU_ITEM_SIZE, SUDOKU_ITEM_RADIUS,
                awards[i], SUDOKU_ITEM_TXT_SIZE, SUDOKU_ITEM_UNACTIVE_TXT_COLOR,
                SUDOKU_ITEM_UNACTIVE_COLOR,
                SUDOKU_ITEM_SHADOW_COLOR
            );
        };
        // -----

        // ----- 右上頂點
        if (i >= RIGHT_TOP_POINT && i < RIGHT_BOTTOM_POINT) {
            // ...
        };
        // -----

        // ----- 右下頂點
        if (i >= RIGHT_BOTTOM_POINT && i < LEFT_BOTTOM_POINT) {
            // ...
        }
        // -----

        // ----- 左下頂點
        if (i >= LEFT_BOTTOM_POINT) {
            // ...
        }
        // -----
    };
}複製程式碼


  • drawSudokuItem() 函式方法

在繪製九宮格的 drawSudoku() 函式方法中,你會發現,我們每一步繪製,都將當前小方塊的座標推到了一個 positions 的全域性變數中;

這個變數會記錄所有小方塊的座標,以及他們的下標;

之後我們在繪製輪跳的小方塊時,就能夠通過 setTimeout() 定時器,規定每隔一段時間,通過下標值 jump_index 取出 positions 變數中的某一組座標資訊,並通過該資訊中的座標繪製一個新的小方塊,覆蓋到原來的小方塊上,結束繪製後,jump_index的值遞增;

這便實現了九宮格的輪跳效果。

而繪製這些小方塊,我們封裝了一個公共的方法:drawSudokuItem();

/**
 * 繪製單個小方塊
 * @param {Num} x            座標
 * @param {Num} y            座標
 * @param {Num} size         小方塊的尺寸
 * @param {Num} radius       小方塊的圓角大小
 * @param {Str} text         文字內容
 * @param {Str} txtSize      文字大小樣式
 * @param {Str} txtColor     文字顏色
 * @param {Str} bgColor      背景顏色
 * @param {Str} shadowColor  底部厚度顏色
 */
function drawSudokuItem(x, y, size, radius, text, txtSize, txtColor, bgColor, shadowColor) {
    // ----- 繪製方塊
    context.save();
    context.fillStyle = bgColor;
    context.shadowOffsetX = 0;
    context.shadowOffsetY = 4;
    context.shadowBlur = 0;
    context.shadowColor = shadowColor;
    context.beginPath();
    roundedRect(
        x, y,
        size, size, 
        radius
    );
    context.fill();
    context.restore();
    // -----

    // ----- 繪製圖片與文字
    if (text) {
        if (text.substr(0, 3) === 'img') {
            let textFormat = text.replace('img-', ''),
                image = new Image();
                image.src = textFormat;

            function drawImage() {
                context.drawImage(
                    image, 
                    x + (size * .2 / 2), y + (size * .2 / 2), 
                    size * .8, size * .8
                );
            };

            // ----- 如果圖片沒有載入,則載入,如已載入,則直接繪製
            if (!image.complete) {
                image.onload = function (e) {
                    drawImage();
                }
            } else {
                drawImage();
            }
            // -----
        }
        else {
            context.save();
            context.fillStyle = txtColor;
            context.font = txtSize;
            context.translate(
                x + SUDOKU_ITEM_SIZE / 2 - context.measureText(text).width / 2,
                y + SUDOKU_ITEM_SIZE / 2 + 6
            );
            context.fillText(text, 0, 0);
            context.restore();
        }
    }
    // ----- 
}複製程式碼

該方法是一個公共的繪製小方塊的方法,它能在初始化時繪製所有“底層”小方塊,在動畫輪跳是,繪製那個移動中的小方塊。


drawSudokuItem() 實現了哪些功能?

  1. 通過 global.js 中的一個 roundedRect() 方法,繪製了一個圓角矩形;(本章暫不討論圓角矩形的繪製方法,如果你感興趣,可以檢視原始碼,或者 GG 一下)
  2. 我們定義了一個全域性變數 awards 陣列來儲存獎品資訊,如果值是普通的字串,則在小方塊的正中繪製該字串文字,如果值帶有字首 img- 我們就將該字串中的 url 地址,作為圖片的地址,渲染到小方塊上。

繪製方塊沒啥好講的,如果你不想用 roudedRect() 方法,你可以直接把它替換成 context.rect(),除了不是圓角,效果完全一樣。


在這裡重點說下 context.drawImage() 這個方法:

先清楚一個概念

  1. 所繪製的影象,叫做 源影象 source image
  2. 繪製到的地方叫做 目標canvas destination canvas

語法

context.drawImage(
    HTMLImageElement $image,
    int $sourceX, int $sourceY [ , int $sourceW, int $sourceH,
    int $destinationX, int $destinationY, int $destinationW, int $destinationH ]
)複製程式碼


引數有點多哈,但本章用到的也就前五個,其中前三個是必選,後兩個是可選引數:

$image       # 可以是 HTMLImageElement 型別的影象物件,
             # 也可以是 HTMLCanvasElement 型別的 canvas 物件,
             # 或 HTMLVideoElement 型別的視訊物件
             # 也就是說,它可以將指定 圖片,canvas,視訊 繪製到指定的 canvas 畫布上。

             # 可以看到,該方法可以繪製另一個 canvas,
             # 我們可以通過這個特性實現 離屏canvas;在以後的章節中我會詳細的講解。

$sourceX / Y # 源影象的座標,用這兩個引數控制圖片的座標位置。

$sourceW / H # 源影象的寬高,用這兩個引數控制圖片的寬度與高度。複製程式碼


⚠️ 這個方法有兩個坑:

  1. 由於圖片地址跨域的?問題,在本地跑是會報錯的,所以我們必須建立一個本地伺服器來做測試;
  2. 如果呼叫該方法時,圖片未被載入,則什麼錯都不報,就是不顯示(任性吧?),解決方法,在 image.onload = function(e) {...} 回撥中呼叫 context.drawImage()


如果你不知道怎麼建立本地伺服器的話,我...,憤怒的我當場百度了一篇最簡單搭建伺服器的教程,童叟無欺!gulp 搭建本地伺服器教程


我們來看以下程式碼:

if (text.substr(0, 3) === 'img') {
    let textFormat = text.replace('img-', ''),
        image = new Image();
        image.src = textFormat;

    function drawImage() {
        context.drawImage(
            image, 
            x + (size * .2 / 2), y + (size * .2 / 2), 
            size * .8, size * .8
        );
    };

    // ----- 如果圖片沒有載入,則載入,如已載入,則直接繪製
    if (!image.complete) {
        image.onload = function (e) {
            drawImage();
        }
    } else {
        drawImage();
    }
    // -----
}複製程式碼
  1. 先檢測獲取的文字字串是否含有字首 img,如果有,便開始繪製圖片;
  2. 將文字的字首去除,格式化後保留完整的連結地址;新建一個 image 物件,將該物件的 src 屬性賦值;
  3. 定義一個 drawImage() 函式方法,在該方法裡面,使用 context.drawImage() 方法渲染剛剛定義的 image 物件,並指定相應的圖片大小,和尺寸;
  4. 通過 image.complete 來判斷圖片是否已載入完成,如果未載入,則先初始化,在 image.onload = function(e) {...} 的回撥中呼叫 drawImage() 方法;如果已經載入完畢,則直接呼叫 drawImage() 方法。


以上,圖片就這樣渲染完成了,渲染普通文字就不用說了哈,就是普通的 context.fillText() 方法。


繪製按鈕:

我們已經將外圍的小方塊繪製完成了,接下來來製作中間的按鈕。

按鈕的繪製很簡單,大家看看原始碼, 就能輕鬆理解;

但是這個按鈕在 canvas 中,只不過就是一堆畫素組成的色塊,它不能像 html 中定義的按鈕那樣,具有點選,滑鼠移動等互動功能;

如果我們想在 canvas 中實現一個按鈕,那我們只能規定當我們點選 canvas 畫布中的某一個區域時,給予使用者反饋;


? 這裡引入一個新的方法,context.isPointInPath()
人如其名,該方法會判斷:當前座標點,是否在當前路徑中,如果在,返回 true,否則返回 false。


語法

context.isPointInPath(int $currentX, int $currentY)複製程式碼

兩個引數就代表需要進行判斷的座標點。


通過這個方法,我們可以判斷:當前使用者點選的位置的座標,是否位於按鈕的路徑中,如果返回 true,則執行抽獎動畫。


⚠️ 值得注意的是,判斷的路徑,必須是當前路徑,也就是說,我們在執行判斷之前需要重新繪製一遍按鈕的路徑;原始碼中的 createButtonPath() 就是為了做這件事情存在的。


我們來做一個簡單的小測試,測試效果如圖4:

var canvas = document.getElementById('canvas'),
    context = canvas.getContext('2d');

function windowToCanvas(e) {
    var bbox = canvas.getBoundingClientRect(),
        x = e.clientX,
        y = e.clientY;

    return {
        x: x - bbox.left,
        y: y - bbox.top
    }
}

context.beginPath();
context.rect(100, 100, 100, 100);
context.stroke();

canvas.addEventListener('click', function (e) {
    var loc = windowToCanvas(e);
    if (context.isPointInPath(loc.x, loc.y)) {
        alert('?')
    }
});複製程式碼

圖4
圖4

怎麼樣?炒雞簡單對吧?在我們這個專案中也是一樣的:

  1. 我們在繪製按鈕的時候,將按鈕的座標資訊已經推送到了 button_position 這個變數中;
  2. 我們只需要通過這些資訊建立一個一樣的按鈕路徑;(只要你不填充路徑,路徑是不會顯示的);
  3. 建立的路徑成為了 當前路徑,我們將點選事件 click 中獲取的座標資訊傳給 context.isPointInPath() 方法,就可以判斷,當前的位置,是否在按鈕的路徑中。
['mousedown', 'touchstart'].forEach((event) => {
    canvas.addEventListener(event, (e) => {
        let loc = windowToCanvas(e);

        // 建立一段新的按鈕路徑,
        createButtonPath();

        // 判斷當前滑鼠點選 canvas 的位置,是否在當前路徑中,
        // 如果為 true,則開始抽獎
        if (context.isPointInPath(loc.x, loc.y) && !is_animate) {
            // ...
        }
    })
});複製程式碼

我們將通過點選按鈕,來呼叫 animate() 方法,該方法實現了九宮格抽獎的動畫效果。


實現動畫:

在點選按鈕時,我們會初始化三個全域性變數,jumping_time, jumping_total_time, jumping_change

它們分別表示:動畫當前時間計時;動畫花費的時間總長;動畫速率改變的峰值(使用 easeOut 函式方法,單位時間內會將速率由0提升到峰值);

最後我們將呼叫 animate() 函式方法,以下是該方法的程式碼:

function animate() {
    is_animate = true;

    if (jump_index < AWARDS_LEN - 1)       jump_index ++;
    else if (jump_index >= AWARDS_LEN -1 ) jump_index = 0;

    jumping_time += 100;  // 每一幀執行 setTimeout 方法所消耗的時間

    // 當前時間大於時間總量後,退出動畫,清算獎品
    if (jumping_time >= jumping_total_time) {
        is_animate = false;
        if (jump_index != 0)       alert(`?恭喜您中得:${awards[jump_index - 1]}`)
        else if (jump_index === 0) alert(`?恭喜您中得:${awards[AWARDS_LEN - 1]}`);
        return;
    };

    // ----- 繪製輪跳方塊
    drawSudoku();
    drawSudokuItem(
        positions[jump_index].x, positions[jump_index].y,
        SUDOKU_ITEM_SIZE, SUDOKU_ITEM_RADIUS, 
        awards[jump_index], SUDOKU_ITEM_TXT_SIZE, SUDOKU_ITEM_ACTIVE_TXT_COLOR,
        SUDOKU_ITEM_ACTIVE_COLOR,
        SUDOKU_ITEM_SHADOW_COLOR
    );
    // -----

    setTimeout(animate, easeOut(jumping_time, 0, jumping_change, jumping_total_time))
}複製程式碼


animate() 函式方法:

  1. 我們定義了一個全域性變數 is_animate,該變數用來阻止使用者在動畫進行時反覆點選按鈕,使動畫不斷被呼叫;該變數初始值為 false,僅當該變數為 false 時,點選按鈕才會進入 animate() 函式;當進入 animate() 函式後,該變數被設定為 true,結束動畫時,又被重置為 false
  2. jump_index 全域性變數的初始值是一個小於等於獎品總數的隨機正整數;隨著每一幀動畫的執行遞增,但當他等於獎品總數時,又會被重置為 0,以此迴圈;我們使用該變數,來繪製輪跳的小方塊;
  3. jumping_time 全域性變數初始值為0,隨著每一幀動畫的執行遞增,以此來記錄動畫當前時間點,當這個值大於等於時間總量 jumping_total_time 時,就可以結束動畫,並將當前的 jump_index 取出,作為抽中的獎品了;
  4. drawSudoku() 方法中第一句程式碼就是:context.clearRect(0, 0 , canvas.width, canvas.height);它用於清理整個畫板,並將九宮格重繪出來;
  5. drawSudokuItem() 我們使用這個函式方法,來繪製輪跳的小方塊;前面說過,我們將 jump_index 做為下標,那麼我們就可以在 positions 變數中找到座標資訊,從 awards 變數中,找到獎品資訊;
  6. 最後,我們使用定時器 setTimeout() 方法,來實現小方塊的動畫;該方法呼叫 animate() 方法本身,它的第二個引數,我們使用了上一章介紹過的緩動函式來定義,這會使動畫看上去由快到慢;緩動函式的原始碼可以在 global.js 中找到。

O 啦~所有程式碼講解完畢,你的九宮格是否也動起來了??


結語:

canvas 實現動畫的方式不外乎就是清除畫板,再重新繪製一個 動作 ,理解了它,無論你是用 window.requestAnimateFrame() 還是 setTimeout() 和 setInterval() 來做動畫,都是一樣的原理;

九宮格的實現很簡單,唯一複雜點的,就需要一系列計算,來繪製一個靈活的九宮格;

九宮格不僅可以用來抽獎,也可以用來做一些小遊戲,還記得小時候玩過的老虎機麼?如圖5:

圖5
圖5

改改樣式,找點圖片,把值取出來做下分數規則判斷,分分鐘搞定呢!

相關文章