【Canvas創作教學】畫個偵測敵人的動態雷達圖網頁

本次創作直播內容透過 Canvas 物件,將網頁當作畫布,繪製不同的圖形,完成模擬偵測敵人的雷達機介面;運用三角函數概念,以及模組化程式,簡單製作出動態的網頁,詳細的步驟解釋,無論有沒有基礎,都能輕鬆跟著說明完成,剩下的就由你自行發揮囉!

今天我們要來製作一個可以掃描出敵人的動態雷達圖,讓隱藏在地圖深處的隨機敵人現身。本次工具主要透過 Canvas 物件,將網頁當作畫布繪製各式不同的圖形,而在觀念上與上一次的時鐘是有些相似的,都運用了三角函數的概念,不過會有一些延伸的知識點,像是如何將相似的圖形以模組化的方式來撰寫,以及敵人在被掃略線掃到時,要如何顯現後再漸漸地消失。

透過此次教學,你會學到

  • 認識 Canvas,並將網頁在當作畫布一樣繪製線條、顏色與形狀
  • 學習觀察相似物件的特性,以模組化的方式呈現,減少程式碼的撰寫

認識常用的 Canvas 屬性

在 Canvas 中,每一次繪圖都是分為一個個區段的,在執行步驟上可分為三個步驟,這裡以像是操作機台白話的方式來做比擬。

  1. 按下繪圖開始樣式按鈕
  2. 設定所要畫的圖形,像是圖形或是線條,可以是單一或是多個都沒問題
  3. 確認上述設定圖形沒問題,開始畫圖,將圖形顯示在畫面上

上述三個步驟對應到 Canvas 的屬性分別是:

  1. 繪圖開始 : beginPath()
  2. 圖形設計 : 在圖形設計上可分為兩種,一種實心呈現色塊的圖形,另一種則是單一線條,或是以單一線條所構成的中空圖形,常見的畫圖方式有以下:
    • 畫正方形 rect(x 位置,y 位置,寬度,高度)
    • 畫弧形 arc(x 位置,y 位置,半徑,起始角度,終點角度)
    • 畫線條時會有兩個屬性搭配使用,分別是 moveTo(x 位置,y 位置) ,這僅會移動畫筆,但不畫線,而另一個則是 lineTo(x 位置,y 位置),這則是會以下筆的方式移動到特定位置。
    在圖形設定上也包含樣式,像是以顏色來說有 fillStyle 用於指定色塊,而 strokeStyle 則是指定線條顏色。
  3. 開始畫圖 : 這裡同樣在兩種不同的圖形有特別的指定方式,在色塊上是 fill(),而線條則是 stroke()

在正式進入本章的主題前,老闆找到了一個線上繪製數學圖形的網站,嘗試以不同的方式來說明極座標的概念。在先前的文章〈來用可怕的三角函數做網頁吧!Part 1Part 2〉說明了以極座標表示位置的方式,而在本次範例中,一樣會使用到極座標概念,想要回顧上次的教學內容,歡迎點選上面文章連結複習一下再開始!

在圖1-1中,看到網頁中的步驟三定義的變數 t,用於表示角度,而步驟四則顯示極座標 (cos(t), sin(t))的表示方式畫出圓點,當移動 t 的拉桿時,同時也代表角度正在改變,可以看到在畫面中的點以座標 (0, 0) 的位置為中心,在周圍以半徑為 1 單位的距離移動。

圖1-1 : 變數 t,用於表示角度
圖1-1

接著在圖 1-2 中的步驟五寫了一個定義圓的方程式,讓前面所提的圓心軌跡顯示出來。

而步驟六則定義了 r 也就是圓的半徑範圍,預設上 r 的半徑大小為 1 ,所以可以看到拖拉變數 t 也就是角度範圍時,圓點就在圓形軌跡上面移動,而當改變半徑 r 時,則會改變整個圓的大小。

圖1-2

為了讓圓點可以在圓的軌跡上,而不是固定在半徑為 1 的範圍中,所以在圖 1-3 的步驟四中,在 x 與 y 的座標位置都乘上了半徑 r,這樣一來,當 r 的大小有變化時,不僅圓的大小會改變,可以看到圓點距離中心點的位置也在改變。

在步驟四中所呈現的就是點在座標系統的呈現方式,其涵蓋了兩個變數,分別是距離中心點位置的半徑 r 以及角度 t

圖1-3
圖1-3

前置作業

設定 Code Pen

在 Code Pen上開一個新的pen,將HTML的預處理器設定成Pug、CSS的預處理器設定成Sass、Js 中引入 Jquery。

引入雲端字形

在此次範例會使用到外部字體來作為搭配,讓作品更好看。

  • 首先進入到 Google Fonts 中,搜尋 Abel 後進入頁面。
  • 在 Styles 區塊中的右側有加號 Select this style,點選後在右側會跑出視窗。
  • 開啟 Code Pen 中的 css 設定,將剛剛所選的連結貼在add another resource新增的欄位中,並點選儲存,這樣一來就可以使用了。

上述都設定好後,就要正式進入主題囉!

一、基礎版面

為了可以畫圖,所以需要一張畫布,那就是 Canvas,並指定 ID 為 #myCanvas。另外放置了訊息,分別是標題,以及一些訊息,這裡先暫時以 temp 作為代稱,這個在後面會更改為顯示掃到敵人時,敵人所在的角度與位置。

//HTML
canvas#myCanvas
.info
    h1 Boss, CODING Please
    p.message temp

接下來在樣式上做初始設定,我們希望是滿版的網頁,所以在長與寬都設置為 100%,而在預設上內距與外距會跑出來,但這些我們也不要,所以在 padding 與 margin 上設置為 0。

屬性 overflow 則是決定當物件超出原本的畫面時,該怎麼處理物件的顯示方式,這裡選擇 hidden,代表超出範圍的即隱藏起來。在字體上則是使用先前在 google font 所引入的 Abel。

//CSS
html, body
  //填滿視窗
  width: 100%
  height: 100%
  padding: 0
  margin: 0
  overflow: hidden
  font-family: Abel
步驟1-1設置基礎版面字樣
步驟1-1設置基礎版面字樣

現在畫面上仍看不到 canvas 的蹤影,所以指定背景顏色 #333 讓它顯示出來,所以發現它小小一個,但我們希望它撐滿整個版面,不過這個效果老闆選擇後續在 js 修改,這裡僅先調整訊息的位置。

訊息要放置在畫面的左下角,所以將它的定位改為絕對定位,並讓它離下方與左側各距離 50px。在字體的顏色上,敵人的訊息顏色是使用老闆特調的黃金色 rgb(185, 147, 98),標題的話則是白色,雖然標題暫時隱藏了,不過待會背景會設置為深色,就會看見標題了。而在這兩行字的間距上老闆希望可以距離近一些,所以將兩者 margin 都拿掉設置為 0。

//CSS
html, body
  //填滿視窗
  width: 100%
  height: 100%
  padding: 0
  margin: 0
  overflow: hidden
  font-family: Abel

canvas
  background-color: #333

.info
  position: absolute
  left: 50px
  bottom: 50px

h1
  color: white
  letter-spacing: 3px
  margin: 0

.message
  margin: 0
  color: rgb(185, 147, 98)
步驟1-2

二、基礎版面樣式設置

畫圖的第一步就是要取得畫布這個元素,在預設上我們有引入 Jquery,所以可以使用錢字號$的方式來抓取元素,所以這裡以錢字號加上在 html 中所設定 canvas 的 ID – #myCanvas,並且記得加上第零個的位置,這樣才會是 html 的元素。

接著處理這張畫布的渲染環境,由於是要在平面的範圍上作圖,所以使用 c.getContext("2d") 來存取的繪圖區域。

有了畫布後,要指定畫布的長寬,讓它可以撐滿整個畫面,所以創建兩個變數分別是 wwwh 來記錄畫面上的寬度與長度。另外,由於後續需要將主要的物件放置在畫面中央,所以也需要創建一個名為 center 的變數來記錄中心點的位置。

接著建立 getWindowSize()函數來指定畫布長寬與中心點,透過錢字號抓取 window 網頁元件,並取其視窗長度與高度的屬性 outerWidth()outerHeight(),放入到變數 ww, wh 中。有了長寬的數值後,就可以將它指定為畫布的長與寬了,而中心點的位置則是將兩者數值都除以 2。

設定好函數後,記得在下面呼叫一次剛剛所撰寫的函式,才會呈現所寫的效果。不過這裡有個問題,當我們拉動網頁視窗時,畫面並不會隨之更新,原因在於函式僅執行了一次,為了解決這個問題,所以需要加上 $(window).resize(getWindowSize) ,代表著當畫面有重新改變大小時,會重複執行一次 getWindowSize() 這個函式。

//JavaScript
var c = $("#myCanvas")[0];
var ctx = c.getContext("2d");
var ww, wh;
var center = { x: 0, y: 0 };

function getWindowSize() {
  //設定大小
  ww = $(window).outerWidth();
  wh = $(window).outerHeight();

  c.width = ww
  c.height = wh

  //重新設定中心點
  center = { x: ww / 2, y: wh / 2 };
}

getWindowSize();
//設定當網頁尺寸變動的時候要重新抓跟設定大小、中心
$(window).resize(getWindowSize);
步驟二:基礎版面畫製
步驟二:基礎版面畫製

三、繪製一個矩形

設定好畫布後,接著要嘗試在畫布上繪製圖形,這裡先來畫畫看一個正方形。建立一個名為 draw()的函式,裡面 ctx 也就剛剛所抓取的畫布名稱,而rect 則代表要繪製一個矩形,參數分別為 (x 起始位置,y 起始位置,寬度,長度)。為了讓它可以呈現出動態的效果,所以使用 setInterval(draw, 10),設定每十毫秒就執行一次 draw(),並且創建一個數值會向上遞增的變數 time 放到 x 位置中,這樣一來物件每十秒就會向前方移動一單位的距離。

//JavaScript
setInterval(draw, 10)
var time = 0;

function draw() {
  time += 1;
  ctx.rect(20 + time, 20, 150, 100);
  ctx.stroke();
}
步驟三:畫一個會跑的矩形
步驟三:畫一個會跑的矩形

但是這個時候會發現,舊的元素並沒有被清除掉。解決這個問題的方法為,在每一次繪圖的時候,也重新再一次指定背景。這邊可以注意到繪製填滿圖形與線條是不一樣的,若是要填滿圖形是 ctx.fill(),而繪製線條的話則是 ctx.stroke(),詳細的原理在第四步驟會做說明。

//JavaScript
function draw() {
  time += 1;

  ctx.fillStyle = "#fff"
  ctx.beginPath();
  ctx.rect(0, 0, 500, 500);
  ctx.fill();

  ctx.rect(0 + time, 0, 50, 50);
  ctx.stroke();
}
步驟三:每畫一個新的圖形前都要再蓋一次背景,才可以清除舊的元素
步驟三:每畫一個新的圖形前都要再蓋一次背景,才可以清除舊的元素

這樣子呈現的效果就沒有問題了,不過我們是要在整張畫布上作畫,當然需要再改變畫布的大小,為了確保可完整地覆蓋背景,所以設置一個很大的數值覆蓋在背景上,並將原本測試使用的矩型移除掉。

//JavaScript
function draw() {
  time += 1;

  //更新為整張畫布大小為黑色+放大
  ctx.fillStyle = "#111"
  ctx.beginPath();
  ctx.rect(-2000, -2000, 4000, 4000);
  ctx.fill();
}
步驟三:改變畫布大小
步驟三:改變畫布大小

這是現在畫面上所呈現的樣子,雖然看上去跟步驟二所呈現出的效果是一樣的,但是現在的這個背景會不斷更新,我們接下來將圖形繪製上去的時候,也才不會造成圖形疊加在一起的問題。

四、畫垂直線

背景設定好後,就要來繪圖囉,不過在開始之前,要先來說明在 canvas 中的座標系統:在canvas 中,當增加 y 數值的時候,會發現物體往畫面的下方移動,這是 canvas 預設的座標系統。而我們所熟悉的座標系統中,X 數值增加是向右,而 Y 數值增加則是向上,所以這裡要調整一下,在 sass 中改變 Y 軸的軸向。

//CSS
canvas
  background-color: #333
  transform: scaleY(-1)
//JavaScript
function getWindowSize() {
  ...
  center = { x: ww / 2, y: wh / 2 };
  ctx.restore();
  ctx.translate(center.x, center.y);
}

接下來就是要繪製 x 軸與 y 軸,線條是從左邊至右邊以及從下面至上面。在畫線上會需要使用到兩個指定,分別是 moveTolineTo,可以想像你手中現在拿著一支筆,moveTo代表手移動至該點的位置,但是不接觸紙張,而lineTo 則是從現在這個位置,將畫筆在紙上移動至 lineTo 所指定的位置上,所以繪製 x 軸上就是先移動至畫面的左側 (-ww / 2, 0) 的位置,接著移動到 (ww / 2, 0)的點上,而 y 軸也是相同的道理。

這邊為何會需要在最後加上 stroke() 呢 ? 原因在於,如果每下達一個指令時,渲染機制就馬上執行畫線的話,這樣是很吃效能的,所以系統是預設讓我們在新增完所有路徑的時候,再使用 stroke()將剛剛所指定的路徑繪製出,同樣的概念也適用於 fill()

//JavaScript
function draw() {
  time += 1;

  ctx.fillStyle = "#111"
  ctx.beginPath();
  ctx.rect(-2000, -2000, 4000, 4000);
  ctx.fill();

  // 畫座標軸
  ctx.strokeStyle = "rgba(255,255,255,0.5)";
  //x
  ctx.moveTo(-ww / 2, 0);
  ctx.lineTo(ww / 2, 0);
  //y
  ctx.moveTo(0, -wh / 2);
  ctx.lineTo(0, wh / 2);
  ctx.stroke();
}
步驟四:畫製X、Y軸
步驟四:畫製X、Y軸

五、利用極座標畫線條

建立好座標軸後,就要來開始挑戰本章的大魔王 – 動態掃略線。首先第一步是要以極座標來畫線,會需要兩個重要的數值,分別是線條的長度 r 以及線條的角度 deg。

線條要以中心點為起點,所以將畫筆移動圓點 (0, 0)位置,接著是移動至另一個端點,也就是( r * cos(角度), r * sin(角度) ),但是此時會發現畫面上所呈現的效果怎麼跟預想的不太一樣,看上去很明顯並非是 45 度。

//JavaScript
var color_gold = "185,147,98";
function draw() {
  ...
  // 以極座標方式繪製線條
  ctx.strokeStyle = "rgba(" + color_gold + ",1)";
  var r = 100;
  var deg = 45;
  ctx.moveTo(0, 0);
  ctx.lineTo(r * Math.cos(deg), r * Math.sin(deg));
  ctx.stroke();
}
步驟五:利用極座標畫線條
步驟五:利用極座標畫線條

原因在於口語上我們表達會是 90度、180度,但是實際上它的單位會是弧度,比如說 180 度相當於是角度 PI,其數值的大小為 3.14,而非 180,所以這裡需要進行單位上的轉換,建立一個 deg_to_pi 變數,並將 π 除上 180,接著我們在 console 裡面試試看呈現出的結果,可以看到角度乘上 deg_to_pi 時,就會是正確的徑度數值,這樣一來後續在定義角度的時候,我們就可以用熟悉的角度來去定義囉。

角度轉換為π (Pi)
角度轉換為π (Pi)

下面將定義的變數 deg_to_pi 與角度 deg 做相乘後,所呈現出就會是正確的徑度數值與角度。

//JavaScript
var color_gold = "185,147,98";
var deg_to_pi = Math.PI / 180;  //新增轉換定義

function draw() {
  ...
  // 以極座標方式繪製線條
  ctx.strokeStyle = "rgba(" + color_gold + ",1)";
  var r = 100;
  var deg = 45;
  ctx.moveTo(0, 0);
  ctx.lineTo(r * Math.cos(deg_to_pi * deg), r * Math.sin(deg_to_pi * deg));
  ctx.stroke();
}
步驟五:呈現出正確的45度角
步驟五:呈現出正確的45度角

做到這邊,確實有達成所希望的效果沒錯,不過如果每次要設定點的位置時,都需要寫一長串的話似乎有點麻煩,所以老闆這邊習慣寫一個可以計算點位置的函數,只需要傳入兩個參數,分別是長度以及角度後,就可以得到一個物件,裡面包含點的 x 位置與 y 位置。

除了更改點的呈現方式之外,這裡有個小地方要注意,就是會發現所有的線條,包含前面設定的 xy 軸都變成了金色,原因在於繪圖系統又重新將它們漆上了金色,為了可以與路徑的設定切分,需要加上一個另一個的 beginPath() 告知繪圖系統要建立一個新的路徑。

//JavaScript
var color_gold = "185,147,98";
var deg_to_pi = Math.PI / 180
// 1. 更改為 function 從極座標轉串成點
function Point(r, deg) {
  return {
    x: r * Math.cos(deg * deg_to_pi),
    y: r * Math.sin(deg * deg_to_pi)
  }
}

function draw() {
  ...
  ctx.strokeStyle = "rgba(" + color_gold + ",1)";
  var r = 100;
  var deg = 45;
  var newpoint = Point(r, deg)  // 2.以函數取得 newPoint,
  ctx.beginPath();  // 3. 為了避免前面的軸線再次重新被描一次而變成金色,所以這裡要加上 beginPath
  ctx.moveTo(0, 0);
  ctx.lineTo(newpoint.x, newpoint.y);
  ctx.stroke();
}

六、扇形掃描線

扇型的掃描線我們不使用 arc 來繪製,是因為無法達成透明度變化的效果,而要構成掃描線的樣式,可以透過每一個單位的線條或是三角形組合而成,這裡先以較為簡單的線條方式來繪製看看。

每次角度改變一度,就繪製一條線條,所以創建一個 for 迴圈,迴圈執行的次數 line_deg_len 也就是弧形的角度大小,在線條角度上以 time 這個變數的數值為基準減去迴圈中的 i 值,這樣就有 100 個連續相差 1 的角度數值,另外因為 time 是會隨時間變化的,連帶著這 100 個角度值也會不斷變化,進而生成了動態的旋轉扇形。

在透明度的設定上,則是在每一條線畫的時候就指定個別的透明度,不過由於透明度的值是 0~1,我們不能直接放 i 值,而是要放 i / line_deg_len

//JavaScript
function draw() {
  ...
  ctx.strokeStyle = "rgba(" + color_gold + ",1)";
  var r = 200;
  var deg = time;
  var newpoint = Point(r, deg)

  var line_deg_len = 100;  // 弧線的角度
  for (var i = 0; i < line_deg_len; i++) {
    var deg = (time - i)
    var newpoint = Point(r, deg)

    ctx.beginPath();
    ctx.strokeStyle = "rgba(" + color_gold + "," + (i / line_deg_len) + ")";
    ctx.moveTo(0, 0);
    ctx.lineTo(newpoint.x, newpoint.y);
    ctx.stroke();
  }
}
步驟六:以線條畫製扇形掃描圖像
步驟六:以線條畫製扇形掃描圖像

現在有一個漸層且會動態旋轉的扇形了,但是有兩個地方怪怪的需要調整:一個是它旋轉的方向反了,透明度較低的線條在前方;另一個則是在以線條的方式繪製下,會有明顯紋路的問題,所以接下來我們要改以畫三角形的方式來試試。

三角形相較於線條會複雜一些些,但是原理上是一樣的,只是線條是兩個點形成一條線,而三角形是三個點形成一個面,相較於線條的單一角度 time - i,還需要另一個相鄰的角度time - i - 1,並以這兩個角度來計算出每一個三角形的兩個頂點位置後,再將頂點與 (0, 0) 的位置連接起來。在最後要將原先用於線條的 stroke() 改為用於色塊的 fill()。這樣透過以小三角型色塊的方式呈現,相較於一條條的線條,在視覺上看起來會更加滑順。

接下來處理透明度的方向問題,由於 i 是由 0 開始的,以至於在一開始線條的透明度為 0,這裡有個小技巧就是以 1 減去原先設定的數值,這樣順序就會從由小至大變成由大至小了。

//JavaScript
function draw() {
  ...

  // 改用三角形畫圖
  var line_deg_len = 100;
  for (var i = 0; i < line_deg_len; i++) {
    // var deg = (time-i)
    // var newpoint = Point(r, deg)
    var deg1 = (time - i - 1)
    var deg2 = (time - i)

    var point1 = Point(r, deg1)
    var point2 = Point(r, deg2)
    var opacity = 1 - (i / line_deg_len)

    ctx.beginPath();
    ctx.fillStyle = "rgba(" + color_gold + "," + opacity + ")";
    ctx.moveTo(0, 0);
    ctx.lineTo(point1.x, point1.y);
    ctx.lineTo(point2.x, point2.y);
    // ctx.stroke();
    ctx.fill()
  }
}
步驟六:改為用三角形繪製掃描線,更為平滑,也改好方向
步驟六:改為用三角形繪製掃描線,更為平滑,也改好方向

七、敵人系統

在第七章敵人系統中是比較大的章節,因此將其拆分成四個小節來做說明,分別有:

  • 如何隨機地產生一組敵人
  • 如何將隨機的敵人擺放至畫面上
  • 如何判定掃略線掃到敵人,並且敵人會做出相對應的變化
  • 調整敵人樣式,使敵人看起來更完整

在開始製作敵人系統的這個章節前,要先來建立一個 Color 的函式,透過傳遞一個參數代表透明度來指定需要的顏色,後續也會比較方便。

//JavaScript
// 建立 Color 函數來做使用
function Point(r, deg){...}

function Color(opacity) {
  return "rgba(" + color_gold + "," + opacity + ")";
}

function draw(){...}

7-1 隨機產生敵人

首先創建一個長度為 10 的陣列,並且在裡面放入空的陣列,在圖A中可以看出 enemies 的值為一排空陣列。

接著透過 map 函數做陣列中元素的轉換,將原本空的陣列放入兩個值,分別為 x 與 y,其結果可在圖B中所見。而實際上我們需要創建的資訊一共有三個,分別是半經 r 、角度 deg 以及透明度 opacity,如圖C。

由於位置是隨機產生的,所以在半徑與角度上都是使用 Math.random() ,這裡值得一提的是,角度我們可以直接使用熟悉的 0~360 的角度系統,原因是在於我們使用先前已經寫好了 Point() 函數取得點的位置,而函數中也已經處理好角度轉換的問題了。

// 建立十個空物件的寫法 (圖A)
var enemies = Array(10).fill({})  

// 若建立十個物件,物件中 key 為 X、Y (圖B)
var enemies = Array(10).fill({}).map(
  function (obj) {
    return {
      x: 5,
      y: 5
    }
})

// 我們要繪製敵人則是需要建立十個空陣列,物件中 key 為半徑 r、角度 deg、透明度 opacity (圖C)
var enemies = Array(10).fill({}).map(
  function (obj) {
    return {
      r: Math.random() * 200,
      deg: Math.random() * 360,
      opacity: 1
    }
})
建立十個空物件的寫法 (圖A)
建立十個空物件的寫法 (圖A)
若建立十個物件,物件中 key 為 X、Y (圖B)
若建立十個物件,物件中 key 為 X、Y (圖B)
我們要繪製敵人則是需要建立十個空陣列,物件中 key 為半徑 r、角度 deg、透明度 opacity (圖C)
我們要繪製敵人則是需要建立十個空陣列,物件中 key 為半徑 r、角度 deg、透明度 opacity (圖C)

7-2 敵人在畫面上顯示

隨機產生出敵人後,接著就是將敵人依序顯示在畫面上。

除了使用 for 迴圈來去存取陣列中的物件外,另一種方式則是使用 forEach,這裡使用 obj 來當作每一個元素的代稱,在每一個 obj 中都有三個屬性可做存取,分別是半徑 r 以及角度 deg以及透明度 opacity。由於這裡是要計算出點的位置,所以僅需要前面兩個值,再放入之前所創建的 Point 函數,便可得到該點所在的確切位置 obj_point 了。

敵人先以圓形來做為表示,要畫圓的方式是使用 arc ,其語法為

arc (中心點 x 位置,中心點 y 位置,圓的半徑,起始角度,終點角度)

由於是要畫完整的圓,所以在角度上設定為 0 至 2π。

//JavaScript
function draw(){
  ...

  enemies.forEach(function (obj) {
    //本體
    ctx.fillStyle = Color(1);
    var obj_point = Point(obj.r, obj.deg);

    ctx.beginPath();
    ctx.arc(
      obj_point.x, obj_point.y,
      10, 0, 2 * Math.PI
    );
    ctx.fill();
  })
}
步驟7-2:以原點代表敵人隨機出現的位置
步驟7-2:以原點代表敵人隨機出現的位置

7-3 當線條角度與敵人一樣時,將敵人透明度變成 1

前面只是確認每一個點確實有被設定,而且都有顯示出來。接著要來實作掃略線的功能,當線條與敵人重疊時,敵人才會顯示,並在一段時間後再次消失。

首先要先定義掃略線,在前面的步驟中,定義了 time 這個變數,在每次執行一次 draw() 時,數值便會向上加 1,而且透過 time 定義角度後,將扇形掃略的效果所畫出來,我們要定義掃略線當下的位置,也是使用到 time 這個變數。

不過前面有提到 time 會隨著執行時間不斷地增加,但是角度的範圍僅限於 0~360,所以需要將 time 取 360 的餘數time % 360,讓數值維持在 0~360 之間。

有了掃略線 line_deg 的角度後,接著就是跟敵人的角度來做比較,當兩者的角度差小於一定數值的時候,就代表兩者有重複到了,在這裡老闆以兩者設為兩者的距離取絕對值後小於 1 ,若是符合條件,便將透明度變為 1。而在敵人出現後,要讓他漸漸消失,等待下一次被掃略後出現,只需要將其透明度乘上一個小於 1 即可,透明度的值便會漸漸從 1 趨近於 0。

//JavaScript
var enemies = Array(10).fill({}).map(
  function (obj) {
    return {
      r: Math.random() * 200,
      deg: Math.random() * 360,
      opacity: 0  //  1. 敵人透明度設為 0
    }
})


function draw() {
  var line_deg = time % 360;  // 2. 定義掃略線
		  
  enemies.forEach(function (obj) {
    //本體
    ctx.fillStyle = Color(obj.opacity);
    var obj_point = Point(obj.r, obj.deg);
		
    ctx.beginPath();
    ctx.arc(
      obj_point.x, obj_point.y,
      10, 0, 2 * Math.PI
    );
    ctx.fill();
		
    // 3. 判定掃略線與敵人位置
    if (Math.abs(obj.deg - line_deg) <= 1) {
      obj.opacity = 1;
    }
    obj.opacity *= 0.99
    })
}
步驟7-3:當掃描線的線條角度與敵人一樣時,將敵人透明度變成 1
步驟7-3:當掃描線的線條角度與敵人一樣時,將敵人透明度變成 1

7-4 修改敵人樣式

目前敵人的樣式上只有單個圓圈而已,顯得有些單調,我們想將敵人的符號加上叉叉,以及一個有動態向外擴展的圓圈。在開始之前,先將原先的圈圈大小做調整,將半徑為 10 大小縮小為 4。

叉叉為兩個線交疊而形成的,而線條的長短代表叉叉的大小。老闆先是定義了 x_size 這數值大小,這是繪製線條值所移動的距離,也代表叉叉的大小。兩條線分別是從左下至右上以及右下至左上,中心點的位置與圓圈同為 (obj_point.x obj_point.y),要移動至左下角時,在 X 座標與 Y 座標的值都是減去 x_size,而右上角的點則是都加上 x_size,其餘另外兩個點則以此類推。

步驟7-4:敵人樣式的叉叉解說
步驟7-4:敵人樣式的叉叉解說

接著來畫向外擴張的圈圈,這裡可以直接複製上面的開始畫圈圈的程式碼,並改成 strokeStyle 以線條的方式呈現。一個向外擴展的圈圈代表半徑的大小隨著時間在不斷變化的,在這裡當然是可以再寫一個變數來代表動態的半徑大小,但是其實我們已經有現成的變數可以使用,也就是透明度,所以不需要再額外寫一個。那就將半徑乘上 obj.opacity 吧,此時會發現圈圈反而是從外向內縮小,原因在於 obj.opacity 就是從 1 趨近於 0 漸漸越來越小的。

其解法就是將 1 除上 obj.opacity後,當透明度值越小,所得到相對應的數值會越大,這裡要特別注意的是,由於在除法中除上 0 是沒有意義的,所以需要加上一個極小的數值來避免掉這樣的情況。

// 圈圈由外向內
ctx.arc(point.x, point.y, 20*opacity
        , 0, 2 * Math.PI);

// 圈圈由內向外
ctx.arc(point.x, point.y, 20*(1/(obj.opacity + 0.001));
        , 0, 2 * Math.PI);

以下是改變了實心圓大小、加上叉叉以及向外擴展圈圈的完成程式碼

enemies.forEach(function (obj) {
  //本體
  ctx.fillStyle = Color(obj.opacity);
  var obj_point = Point(obj.r, obj.deg);

  ctx.beginPath();
  ctx.arc(
          obj_point.x, obj_point.y,
          4, 0, 2 * Math.PI  //  1. 半徑縮小為 4
  );
  ctx.fill();

  if (Math.abs(obj.deg - line_deg) <= 1) {
    obj.opacity = 1;
  }
  obj.opacity *= 0.99



  // 2. 畫叉叉   
  ctx.strokeStyle = Color(obj.opacity);
  var x_size = 6;
  ctx.lineWidth = 4;
  ctx.beginPath();
  ctx.moveTo(obj_point.x - x_size, obj_point.y - x_size);
  ctx.lineTo(obj_point.x + x_size, obj_point.y + x_size);
  ctx.moveTo(obj_point.x + x_size, obj_point.y - x_size);
  ctx.lineTo(obj_point.x - x_size, obj_point.y + x_size);
  ctx.stroke();

  // 3. 往外消失的圓線
  ctx.strokeStyle = Color(obj.opacity);   // 線條是strokeStyle
  ctx.lineWidth = 1;

  var point = Point(obj.r, obj.deg);
  var r = 20 * (1 / (obj.opacity + 0.001));

  ctx.beginPath();
  ctx.arc(point.x, point.y, r
          , 0, 2 * Math.PI);
  ctx.stroke(); // 線條是stroke()
}
步驟七:完成雷達掃描出現敵人的動態
步驟七:完成雷達掃描出現敵人的動態

八、修改左下角文字

在掃略線掃到敵人後,除了讓敵人顯現外,當然也要將敵人的位置標示出來。在判定掃略線與敵人碰觸到的判斷式中,用 jquery 的方式抓取左下角顯示文字的元素 .message,再填上要顯示的文字,分別是距離中心的半徑長度以及角度。在預設上系統會顯示非常多位數的小數點,這裡可以透過 toFixed(3) 來限制小數點所呈現的位數,括號內的數值即代表要呈現小數點幾位數,這樣一來讓視覺上比較好看一些。

if (Math.abs(obj.deg - line_deg) <= 1){
  obj.opacity=1;
  $(".message").text("Detected: "+obj.r.toFixed(3) +" at "+ obj.deg.toFixed(3) + "deg");
}

九、外圍刻度

在外圍的刻度上要先定義幾個四個變數來使用,分為是

split : 將圓形切分的份數。
feature : 每隔幾度要以比較長的線條呈現,就像家裡時鐘整點位置的線條會長一點。
tart_r : 距離中心點的起始位置。外圍刻度要為在掃略線的外圍,所以數值比掃略線的 200 再多一些。
len : 線條的長度。

在角度的要特別注意需要轉換,deg=(i/split)*360,這是因為在切分上只有切成了 120 份而非 360 份,所以 i 值並非代表角度,而是要先以i/split 判斷是第幾份,再將數值乘上 360 才會是正確的角度。後續則是將靠內側的點以及靠外側的點計算出來後,再相連就可以囉。

接著再使用判斷式 i % feature == 0,也就是當每隔 15 個單位時,將線條的長度以及粗度都設定得比其他線條的數值更大一些。

function draw(){
  ...

  ctx.strokeStyle=Color(1);
  var split=120;
  var feature=15;
  var start_r=230;
  var len=5;
		  
  for(var i=0;i<split;i++){
  ctx.beginPath();
  // 角度要轉換成 360
  var deg=(i/split)*360;
         
  // 如果在大刻度上就變粗變長
  if (i % feature == 0) {
    len = 10;
    ctx.lineWidth = 3;
  } else {
    len = 5;
    ctx.lineWidth = 1;
   }
		    
  // 轉換內側點以及外側點的位置
  var point1=Point(start_r,deg);
  var point2=Point(start_r+len,deg);
		    
  ctx.moveTo(point1.x,point1.y);
  ctx.lineTo(point2.x,point2.y);
		    
  ctx.stroke();
  }
}
步驟九:增加外圍刻度
步驟九:增加外圍刻度

十、畫龍點睛的線條

剩下還有三個不同的線條需要繪製,分別是最內側的虛線、與掃略線半徑相同的實線,以及最外圍由兩個超過四分之一弧形所組成的外框。在最後一個步驟中,要嘗試使用函式帶入參數的方式來一次繪製三種不同的線條,這也是本次範例中相當精彩的地方。

由於一個圓為 360 度,所以設定一個 1 到 360 的 迴圈,並且透過先前寫過的點位置的函數轉換,轉換成 point,再將這 360個連接在一起。其中從外部所傳進來的參數 r 所代表的為半徑的大小。

function draw(){
  ...

  function CondCircle(r) {
    ctx.beginPath();
    ctx.strokeStyle = Color(1);

    for (var i = 0; i <= 360; i++) {
      var point = Point(r, i);
      ctx.lineTo(point.x, point.y);
    }
    ctx.stroke();
  }
			
  CondCircle(300)
}!
步驟十:先畫個實線
步驟十:先畫個實線

以上是實線的呈現方式,那虛線呢 ? 來看看下圖吧 !

虛線繪製方法解說
虛線繪製方法解說

想像拿的一支畫筆,當從每一個點前往下一個點的位置時,也就是 i 至i+1,可以選擇這一次是要用 lineTo 下筆畫線,還是用 moveTo 不要畫線只要移動就好。上面線條是的虛與實是剛好以 1:1 的方式繪製,那如果是要以其他比例,像是下圖呢 ?

不同比例的需線畫製解說
不同比例的需線畫製解說

假設圖片中所呈現有畫線與無畫線的比例是 4:1 ,那就是抓成五等分,有其中四分要畫線,而一份不須畫線,而這可以使用取餘數的方式來完成,下面實作就以每 180 度為一個區塊,在每一個區塊中其中的 90 度以畫線,另外的 90 度則不會畫線。

function draw(){
  ...

  function CondCircle(r) {
    ctx.beginPath();
    ctx.lineWidth = 1;
    ctx.strokeStyle = Color(1);

    for (var i = 0; i <= 360; i++) {
      var point = Point(r, i);

      if (i % 180 < 90) {
        ctx.lineTo(point.x, point.y);
      } else {
        ctx.moveTo(point.x, point.y);
      }
    }
    ctx.stroke();
  }
			
  CondCircle(300)
}!
步驟十:完成虛線畫製
步驟十:完成虛線畫製

有了使用餘數來畫線的概念後,接著就是要來集大成的時候了。要將決定畫不畫線的地方也就是 i % 180 < 90 改為傳送一個函式進來的方式來進行判斷,這裡命名為 func_cond,另外增加一個可以設定線條粗度的變數lwidth

原本的四分之一弧形取餘數的數值不變,但而外加上了 time,使其可以產生出動態的效果。最內側的虛線則是每個區塊為三等份,其中一等分不畫線,所以回傳值為 (deg % 3) < 1,而與掃略相同的圓圈是實線,代表每一條線條都要連起來,在回傳值上都是 true

function draw(){
  ...

  function CondCircle(r, lwidth, func_cond) {
    ctx.beginPath();
    ctx.lineWidth = lwidth;
    ctx.strokeStyle = Color(1);
		
    for (var i = 0; i <= 360; i++) {
      var point = Point(r, i);
      if (func_cond(i)) {
        ctx.lineTo(point.x, point.y);
      } else {
        ctx.moveTo(point.x, point.y);
      }
    }
    ctx.stroke();
  }
		
  // CondCircle(300)
		
  // 最外圍的四分之一弧形
  CondCircle(300, 2, function (deg) {
    return ((deg + time / 10) % 180) < 90;
  });

  //  最內側的虛線
  CondCircle(100, 1, function (deg) {
    return (deg % 3) < 1;
  });

  // 與掃略線相同長度的實線
  CondCircle(200, 1, function (deg) {
    return true;
  });
}!
步驟十:產生動態虛線,並完成本次作品<偵測敵人的動態雷達圖網頁>
步驟十:產生動態虛線,並完成本次作品<偵測敵人的動態雷達圖網頁>

回顧

在本次範例中,可以發現一整個作品幾乎都是由 Canvas 所繪製出來的,就讓我們一起來回顧製作的流程吧

  1. 建構畫面上所需要的文字與本章主角 Canvas
  2. 抓取 Canvas 物件,並設定畫面長寬可隨頁面大小做更動
  3. 在 Canvas 繪製一個動態圖形,並調整底層背景樣式,讓所繪製圖形不會疊加在一起
  4. 正式開始繪製所需要要的圖形,從建立 X 軸與 Y 軸,再從單一的線條到以多個線條來形成掃描扇形
  5. 建立敵人系統是此次最重要的章節,隨機生成敵人的位置,並與掃略線做搭配,以及設定掃到敵人時所需要呈現的樣式
  6. 加上外圍刻度
  7. 以模組化的方式畫出中內外的線條三種不同的線條

透過這次範例讓我們見識到 Canvas 厲害之處,原來它真的就像畫筆一樣,可以畫出這麼美感與創意兼具的作品,想要看更多動態網頁、互動藝術作品,你可以加入老闆的動畫互動網頁程式入門 (HTML/CSS/JS)或是動畫互動網頁特效入門(JS/CANVAS),或來訂閱老闆 youtube 頻道吧!

此篇直播筆記由幫手 阮柏燁 協助整理

墨雨設計banner
PHP Code Snippets Powered By : XYZScripts.com