上一章講解了如何使用 canvas 實現大轉盤抽獎點選回顧;但有些地方並沒有講清楚,比如上一章實現的大轉盤,獎品選項只能填文字,而不能放圖片上去。 這一次,我們用 canvas 來實現九宮格抽獎(我已沉迷抽獎無法自拔~),順便將渲染圖片功能也給大家過一遍。 本章涉及到的知識點,主要有:
context.drawImage()
方法渲染圖片context.isPointInPath()
方法,在畫布中製作按鈕setTimeout()
方法,來做逐幀動畫- 九宮格的繪製演算法
專案結構:
因為本章程式碼比較繁雜,我不會全部貼出來;建議進入我的 Github 倉庫,找到 test 檔案下的 sudoku資料夾下載,本章講解的程式碼都在裡面啦。
|--- js
|--- | --- variable.js # 包含了所有全域性變數
|--- | --- global.js # 包含了本專案所用到的公用方法
|--- | --- index.js # 九宮格主體邏輯程式碼
|--- index.html
複製程式碼
繪製九宮格:
首先,我們需要繪製出一個九宮格,大家都知道九宮格長什麼樣子哈,簡單的排9個方塊,不就搞定了麼?
不不不,作為一名合格的搬磚工,我們需要嚴於律己,寫程式碼要抽象,要能重用,要... 假如哪天產品大大說,我要12宮格兒的,15的,20的,你咋辦,一個個重新算額~ 所以,我們得做成圖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:
聰明的小夥伴們應該已經發現規律了,在圖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:
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,
複製程式碼
- 通過四個頂點,繪製九宮格
得到了每個頂點的下標,那就意味著我們知道了一個頂點距離另一個頂點之間,有多少個小方塊,那麼接下來就非常好辦了,
- 我們可以通過
AWARDS_TOP_DRAW_LEN
乘以4,來獲取總的獎品個數,作為迴圈條件(AWARDS_LEN
); - 我們可以獲取整個矩形的寬度,預設就讓它等於 canvas 的寬度(
SUDOKU_SIZE
); - 自定義每個小方塊之間的間距(
SUDOKU_ITEM_MARGIN
); - 通過矩形的寬度除以一排繪製的小方塊的數量,再減去小方塊之間的間距,得到每個小方塊的尺寸(
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() 實現了哪些功能?
- 通過
global.js
中的一個roundedRect()
方法,繪製了一個圓角矩形;(本章暫不討論圓角矩形的繪製方法,如果你感興趣,可以檢視原始碼,或者 GG 一下) - 我們定義了一個全域性變數
awards
陣列來儲存獎品資訊,如果值是普通的字串,則在小方塊的正中繪製該字串文字,如果值帶有字首img-
我們就將該字串中的 url 地址,作為圖片的地址,渲染到小方塊上。
繪製方塊沒啥好講的,如果你不想用 roudedRect()
方法,你可以直接把它替換成 context.rect()
,除了不是圓角,效果完全一樣。
在這裡重點說下 context.drawImage()
這個方法:
先清楚一個概念:
- 所繪製的影像,叫做 源影像
source image
; - 繪製到的地方叫做 目標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 # 源影像的寬高,用這兩個引數控制圖片的寬度與高度。
複製程式碼
⚠️ 這個方法有兩個坑:
- 由於圖片地址跨域的?問題,在本地跑是會報錯的,所以我們必須建立一個本地伺服器來做測試;
- 如果呼叫該方法時,圖片未被載入,則什麼錯都不報,就是不顯示(任性吧?),解決方法,在
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();
}
// -----
}
複製程式碼
- 先檢測獲取的文字字串是否含有字首
img
,如果有,便開始繪製圖片; - 將文字的字首去除,格式化後保留完整的連結地址;新建一個
image
物件,將該物件的src
屬性賦值; - 定義一個
drawImage()
函式方法,在該方法裡面,使用context.drawImage()
方法渲染剛剛定義的image
物件,並指定相應的圖片大小,和尺寸; - 通過
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('?')
}
});
複製程式碼
怎麼樣?炒雞簡單對吧?在我們這個專案中也是一樣的:
- 我們在繪製按鈕的時候,將按鈕的座標資訊已經推送到了
button_position
這個變數中; - 我們只需要通過這些資訊建立一個一樣的按鈕路徑;(只要你不填充路徑,路徑是不會顯示的);
- 建立的路徑成為了
當前路徑
,我們將點選事件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() 函式方法:
- 我們定義了一個全域性變數
is_animate
,該變數用來阻止使用者在動畫進行時反覆點選按鈕,使動畫不斷被呼叫;該變數初始值為false
,僅當該變數為false
時,點選按鈕才會進入animate()
函式;當進入animate()
函式後,該變數被設定為true
,結束動畫時,又被重置為false
; jump_index
全域性變數的初始值是一個小於等於獎品總數的隨機正整數;隨著每一幀動畫的執行遞增,但當他等於獎品總數時,又會被重置為 0,以此迴圈;我們使用該變數,來繪製輪跳的小方塊;jumping_time
全域性變數初始值為0,隨著每一幀動畫的執行遞增,以此來記錄動畫當前時間點,當這個值大於等於時間總量jumping_total_time
時,就可以結束動畫,並將當前的jump_index
取出,作為抽中的獎品了;drawSudoku()
方法中第一句程式碼就是:context.clearRect(0, 0 , canvas.width, canvas.height)
;它用於清理整個畫板,並將九宮格重繪出來;drawSudokuItem()
我們使用這個函式方法,來繪製輪跳的小方塊;前面說過,我們將jump_index
做為下標,那麼我們就可以在positions
變數中找到座標資訊,從awards
變數中,找到獎品資訊;- 最後,我們使用定時器
setTimeout()
方法,來實現小方塊的動畫;該方法呼叫animate()
方法本身,它的第二個引數,我們使用了上一章介紹過的緩動函式來定義,這會使動畫看上去由快到慢;緩動函式的原始碼可以在global.js
中找到。
O 啦~所有程式碼講解完畢,你的九宮格是否也動起來了??
結語:
canvas 實現動畫的方式不外乎就是清除畫板,再重新繪製一個 動作
,理解了它,無論你是用 window.requestAnimateFrame()
還是 setTimeout() 和 setInterval()
來做動畫,都是一樣的原理;
九宮格的實現很簡單,唯一複雜點的,就需要一系列計算,來繪製一個靈活的九宮格;
九宮格不僅可以用來抽獎,也可以用來做一些小遊戲,還記得小時候玩過的老虎機麼?如圖5:
改改樣式,找點圖片,把值取出來做下分數規則判斷,分分鐘搞定呢!