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

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

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

Github 倉庫 | demo 預覽html

掃描二維碼預覽demo

項目結構:

由於本章代碼比較繁雜,我不會所有貼出來;建議進入個人 Github 倉庫,找到 test 文件下的 sudoku文件夾下載,本章講解的代碼都在裏面啦。java

|--- js
|--- | --- variable.js  # 包含了全部全局變量
|--- | --- global.js    # 包含了本項目所用到的公用方法
|--- | --- index.js     # 九宮格主體邏輯代碼
|--- index.html
複製代碼

繪製九宮格:

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

不不不,做爲一名合格的搬磚工,咱們須要嚴於律己,寫代碼要抽象,要能重用,要... 假如哪天產品大大說,我要12宮格兒的,15的,20的,你咋辦,一個個從新算額~ 因此,咱們得作成圖1這樣的:github

圖1

敲敲數字,鳥槍變大炮。無論你9宮仍是12宮仍是自宮,哥都不怕。算法

如下是個人實現方法,若是你們有更簡單的方法,請告訴我,請告訴我,請告訴我,學美術出生的我數學真的很爛~gulp


  • 九宮格的四個頂點

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


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

如圖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
因此咱們得出一個變量 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

怎麼樣?炒雞簡單對吧?在咱們這個項目中也是同樣的:

  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

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

相關文章
相關標籤/搜索