canvas 彙整 | Creative Coding TW - 互動程式創作台灣站 https://creativecoding.in/category/tutorial/canvas/ 蒐集互動設計案例、教學與業界資源,幫助你一起進入互動程式創作的產業 Mon, 30 May 2022 14:33:51 +0000 zh-TW hourly 1 https://wordpress.org/?v=6.2.2 https://creativecoding.in/wp-content/uploads/2022/03/cropped-cct-logo-icon-2-32x32.png canvas 彙整 | Creative Coding TW - 互動程式創作台灣站 https://creativecoding.in/category/tutorial/canvas/ 32 32 【互動網頁程式教學】活用GUI Object與繼承概念,完成Canvas物件導向的滑鼠拖曳互動 https://creativecoding.in/2022/06/16/gui-object-canvas/ Thu, 16 Jun 2022 02:19:00 +0000 https://creativecoding.in/?p=2879 利用GUI Object的概念,快速畫出多個物件,利用canvas物件導向概念加上事件偵測,讓滑鼠位置與物件互動,達成亮度的提示以及拖曳物件。

這篇文章 【互動網頁程式教學】活用GUI Object與繼承概念,完成Canvas物件導向的滑鼠拖曳互動 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
很多的讀者經常使用像是canvas這樣的函式庫,東西已經包裝成物件,可以輕鬆的拖曳、偵測點擊。這次的老闆週四寫程式,要來教大家在不使用函示庫的情況下,直接使用純繪圖的canvas生成物件,並偵測事件,來用滑鼠點選跟拖曳物件。

Canvas 物件導向與繼承,來做個土炮架構控制元件吧!(上)成品圖
Canvas 物件導向與繼承,來做個土炮架構控制元件吧!(上)成品圖

目標

這篇文章將會讓你學到:

  1. 向量的基礎知識
  2. 把純繪圖的canvas包成物件的架構
  3. 利用物件導向完成元件與滑鼠的基礎互動

架構

此次影片要實作圖形使用者介面(Graphical User Interface,本篇後以GUI稱之)作為小畫家的操作介面。GUI是採用圖形方式顯示的使用者介面,讓使用者可以使用滑鼠或相關設備操縱螢幕上的圖標或菜單選項,跟早期的電腦使用命令列介面相比,讓使用者在視覺上更容易接受。

實作GUI前,可以先了解一下物件設計的架構:

  1. 成品中的每個方塊都是一個GUI object
  2. 而所有的方塊可以形成一個GUI group
  3. 最後再交由GUI scene 畫出整個場景
GUI結構示意圖
GUI結構示意圖
GUI結構範例
GUI結構範例

了解向量

向量加法示意圖
向量加法示意圖

在畫布的操作中,位置是很重要的一環,位置通常由座標(通常是x,y)構成,物件的起始位置與終點位置,可以看作為「座標的變化量」,而「向量」正是可以輕鬆的表示出座標的變化。(小觀念:向量的定義為 方向的變化量

在程式碼中使用「向量」概念

先來做一個向右跑的小方塊作為使用向量的範例。我們把 HTML 的 Preprocessor 設為 Pug 再把 CSS 設為 Sass,在 HTML 中先畫一個 canvas#mycanvas。

使用「座標」畫出白色方塊物件並讓物件向右移動:

// JS setting
var ww,wh
var canvas = document.getElementById("mycanvas")
var ctx = canvas.getContext("2d")
function init(){
  ww = window.innerWidth
  wh = window.innerHeight
  canvas.width = ww
  canvas.height = wh
}
init()
window.addEventListener("resize",init)
 
//使用「座標」定義物件
var obj = {
  p:{
    x: 0,
    y:0
  },
  size:{
    x:100,
    y:100
  },
  v:{
    x:5,
    y:0
  }
}
function draw(){
  ctx.fillStyle="black" //把上一個畫面蓋掉
  ctx.fillRect(0,0,ww,wh) //把上一個畫面蓋掉
  ctx.fillStyle="white"  
  ctx.fillRect(obj.p.x,obj.p.y,obj.size.x,obj.size.y)
}
setInterval(draw,30)
 
function update(){
  obj.p.x += obj.v.x
  obj.p.y += obj.v.y
}
 
setInterval(update,30)

可以從這一段程式碼中,看到定義物件的位置時,需要個別設置x與y的變量,在 update 物件位置時,也需要個別對x與y做處理。

使用「向量」畫出物件及物件移動:

// 定義向量class,並加入向量的加(add)減(sub)乘(mul)運算
class Vec2{
  constructor(x,y){
    this.x=x
    this.y=y
  }
  add(v){
    return new Vec2(this.x+v.x,this.y+v.y)
  }
  mul(s){
    return new Vec2(this.x*s,this.y*s)
  }
  sub(v){
    return this.add(v.mul(-1))
  }
}
 
var obj = {
  p: new Vec2(0,0), // new 一個向量物件
  size:new Vec2(100,100), // new 一個向量物件
  v: new Vec2(5,0) // new 一個向量物件
}
function draw(){
  ctx.fillStyle="black"
  ctx.fillRect(0,0,ww,wh)
  ctx.fillStyle="white"  
  ctx.fillRect(obj.p.x,obj.p.y,obj.size.x,obj.size.y)
}
setInterval(draw,30)
 
function update(){
  obj.p = obj.p.add(obj.v) //使用向量概念中的「相加」
}

setInterval(update,30)

先實作出一個向量類 Vec2,後續使用 new Vec2 就可以使用向量。在後續需要大量生成物件時,可以比座標更容易生成大量物件。

小結:使用向量物件讓程式碼更簡潔,也讓位置設置變得更容易了。

實作

此次實作會用到老師預先製作好了template,內容包含了向量類 Vec2、畫布設置以及一些滑鼠的事件及記錄,可以參考這裏,需要的同學可以fork一份回自己的codePen再往下繼續做呦!

製作物件

一開始需要建立一個GUIObject 類,方便後續快速製造出大量的GUI object。另外再預先定義一個Scene類,並且在裡面增加 addChild(),把所有的GUIObject可以放在children 中,方便畫出。

class  GUIObject{
  constructor(args){
    let def={
      p: new Vec2(0,0),
      size: new Vec2(0,0)
    }
    Object.assign(def,args)
    Object.assign(this,def)
  }
  draw(){
    ctx.fillStyle="white"
    ctx.fillRect(this.p.x,this.p.y,this.size.x,this.size.y)
  }
}
 
class Scene{
  constructor(args){
    let def={
      children: []
    }
    Object.assign(def,args)
    Object.assign(this,def)
  }
  addChild(obj){
    this.children.push(obj)
  }
  draw(){
    this.children.forEach(obj=>{
      obj.draw()
    })
  }
}

接著可以利用剛剛定義好的GUIObject直接畫出一個長方形。

var rect = new GUIObject({ 
  p: new Vec2(30,30),
  size: new Vec2(100,30)
})
 
function draw(){
  ...
  rect.draw()
  ...
}
利用物件導向畫出一個方塊
利用物件導向畫出一個方塊

函式多載

現在要來做一點程式碼的優化。

函式多載是讓一個同名函式帶入的參數可以有不同類型跟不同數量。在函式內做類型判別跟數量判別,可以讓後續在呼叫函式的時候更容易。

現在scene的addChild只能放入一個物件,並且無法判斷進入的物件是不是scene期待的GUIObject。因此要在這邊修改addChild,讓user給什麼都可用,無論是給一個GUIObject、給多個GUIObject或給一個array都可以運行。

addChild(){
  if (arguments.length==1){ // 如果input的arguments只有一個
    if(arguments[0] instanceof GUIObject){ // 如果input的剛剛好是GUIObject
      this.children.push(arguments[0])
    }
    if(arguments[0] instanceof Array){ // 如果input的是array,這邊暫時不檢查裡面是否皆為GUI object
      this.children = this.children.concat(arguments[0])
    }
  }else{
    for(var i=0;i<arguments.length;i++){ // 如果input了很多個object
      this.children.push(arguments[i])
    }
  }
}

addChild得到的input為arguments, 可以看到程式碼中對arguments進行判斷與操作。我們在這邊繪製兩個長方形,讓Scene蒐集GUIObject後一起畫出。(後續會使用這個方式)

var scene = new Scene()
function init(){
  let rect = new GUIObject({
    p: new Vec2(30,30),
    size: new Vec2(100,30)
  })
  let rect2 = new GUIObject({
    p: new Vec2(130,30),
    size: new Vec2(200,200)
  })
  scene.addChild(rect,rect2) // 防呆裝置已啟動
}
function draw(){
  scene.draw()
}
利用修改後的function生成多個方塊
利用修改後的function生成多個方塊

物件與滑鼠互動

完成物件後,我們最後要加入滑鼠與物件的幾種互動。

(1) 滑鼠靠近方塊,改變顏色

這個小動態的目的是要讓user知道滑鼠有沒有放在物件上,先讓物件亮度降低,當物件與滑鼠位置重疊,便讓物件亮度上升:

a. 新增一個 isHovering 參數,如果是true就調整物件亮度

class GUIObject{ 
  constructor(args){
    let def={
      ...,
      isHovering: false // 新增一個 isHovering 參數,預設為false
    }
   ...
  }
  draw(){
    ctx.fillStyle="rgba(255,255,255,0.4)"
    if (this.isHovering){
      ctx.fillStyle="rgba(255,255,255,1)"
    }
    ...
  }
}

b. 要偵測滑鼠位置(在Scene 類加入事件偵測)

class Scene{ 
  constructor(args){
    let def={
      ...,
      el: null // 要知道是哪個document, 在new的時候指定
    }
    ...
    this.init()
  }
  init(){
    // 事件偵測
    this.el.addEventListener("mousemove",(evt)=>{
      let mousePos = new Vec2(evt.x,evt.y) // 拿滑鼠位置
      // 處理滑鼠移動
      this.children.forEach(obj=>{
        obj.handleMouseMove(mousePos) //傳入滑鼠位置
      })
    })
  }
}
 
// 在new的時候指定document
var scene = new Scene({
  el: document.querySelector("canvas")
})

c. 加入 handleMouseMove 來改變 isHovering 參數的值

class GUIObject{
  ...
  handleMouseMove(pos){
    let point1 = this.p,
        point2 = this.p.add(this.size)
    // 判斷滑鼠是否在物件範圍內
    if (pos.x>point1.x && pos.x < point2.x &&
      pos.y>point1.y && pos.y < point2.y){
      this.isHovering = true
    }else{
      this.isHovering = false
    }
  }
}
滑鼠靠近方塊,改變方塊顏色並改變鼠標圖示
滑鼠靠近方塊,改變方塊顏色並改變鼠標圖示

(2) 用滑鼠拖移方塊

當滑鼠位置與物體重疊時,點擊滑鼠右鍵,可以拖移物件:

a. 新增 isDraggable 參數來決定物件是否可以被拖移

class GUIObject{
  constructor(args){
    let def={
      ...
      isDraggable: false
    }
    ...
  }
 
// 在new GUIObject時要記得把 isDraggable設成true
function init(){
  let rect = new GUIObject({
    p: new Vec2(30,30),
    size: new Vec2(100,30),
    isDraggable: true
  })
}

b. 新增 InRange function 來判斷滑鼠位置是否在物件內 (可以重複使用)

class GUIObject{
  ...
  inRange(pos){
    let point1 = this.p,
        point2 = this.p.add(this.size)
    return pos.x>point1.x && pos.x < point2.x &&
           pos.y > point1.y && pos.y < point2.y
  }
  ...
}

c. 新增 dragging 參數來判斷物件是否正在被拖移中

class GUIObject{
  constructor(args){
    let def={
      ...
      isDraggable: false,
      dragging: false
    }
   ...
  }
}

d. 新增 handleMouseDown 及 handleMouseUp 來處理滑鼠按鍵的事件處理

class GUIObject{
  ...
  handleMouseMove(pos){
  if (this.inRange(pos) ){
    this.isHovering = true
  }else{
    this.isHovering = false
  }
  if (this.dragging){
    this.p = this.p.add(pos.sub(this.lastPos))
    this.lastPos = pos
  }
}
 
handleMouseDown(pos){
  if (this.inRange(pos)){
    this.lastPos = pos
    if (this.isDraggable){
      this.dragging = true
    }
  }
}
handleMouseUp(){
  this.lastPos = null
  this.dragging = false
}

e. 事件偵測加入滑鼠按鍵按下與鬆開的偵測

class Scene{
  ...
  init(){
    ...
    // 按下滑鼠按鍵
    this.el.addEventListener("mousedown",(evt)=>{
      let mousePos = new Vec2(evt.x,evt.y)
      this.children.forEach(obj=>{
        obj.handleMouseDown(mousePos)
      })
    })
    // 放開滑鼠按鍵
    this.el.addEventListener("mouseup",(evt)=>{
      let mousePos = new Vec2(evt.x,evt.y)
      this.children.forEach(obj=>{
        obj.handleMouseUp(mousePos)
      })
    })
 ...

f. 改變鼠標,當鼠標指到物件時,鼠標會從箭頭變成👉🏻

class Scene{
  constructor(args){
    let def={
      …
      flag: false
    }
  }
  ...
  update(){
    if(this.flag){
      this.el.style.cursor="pointer"
    }else{
      this.el.style.cursor="initial"
    }
  }
  init(){
    let flag = false
    this.el.addEventListener("mousemove",(evt)=>{
      this.flag = false
      let mousePos = new Vec2(evt.x,evt.y)
      this.children.forEach(obj=>{
        if(obj.handleMouseMove(mousePos)){
          this.flag = true
        }
      })
    })
  }
  ...
}
// 讓scene update起來
function update(){
  time++
  scene.update()
}
滑鼠拖移方塊位置成果圖
滑鼠拖移方塊位置成果圖

完成滑鼠與物件的基礎互動啦!

結語

以上就是老闆利用Canvas做滑鼠控制物件的網頁基礎互動,這次在前面講解概念的部分花了較多時間,來不及完成小畫家,希望對於剛開始接觸 Canvas 的你有一些幫助。

再次快速總結步驟:

  1. 利用GUI Object的概念,快速畫出多個物件
  2. 加上事件偵測,讓滑鼠位置與物件互動,達成亮度的提示
  3. 加上滑鼠按鍵的事件偵測,讓滑鼠拖移物件

看著框框跟隨著滑鼠的移動,真的很有成就感啊!

老闆的工商時間

想了解更多如何寫出漂亮清晰的網頁嗎?老闆在 Hahow 的教學課程 動畫互動網頁程式入門(HTML/CSS/JS) 用平易近人的語言,用簡單的方式帶你作出不簡單的網頁。已經有網頁程式基礎了嗎?進階課程 動畫互動網頁特效入門(JS/CANVAS) 能讓你紮實掌握 JavaScript 程式與動畫基礎以及進階互動,整合應用掌控資料與顯示的Vue.js前端框架,完成具有設計感的互動網站。

此篇直播筆記由幫手 Y-Y-H 協助整理

墨雨設計banner

這篇文章 【互動網頁程式教學】活用GUI Object與繼承概念,完成Canvas物件導向的滑鼠拖曳互動 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
【Canvas創作教學】畫個偵測敵人的動態雷達圖網頁 https://creativecoding.in/2021/09/23/canvas-creation-enemies-radar/ Thu, 23 Sep 2021 02:45:00 +0000 https://creativecoding.in/?p=1450 本次創作直播內容透過 Canvas 物件,將網頁當作畫布,繪製不同的圖形,完成模擬偵測敵人的雷達機介面;運用三角函數概念,以及模組化程式,簡單製作出動態的網頁,詳細的步驟解釋,無論有沒有基礎,都能輕鬆跟著說明完成,剩下的就由你自行發揮囉!

這篇文章 【Canvas創作教學】畫個偵測敵人的動態雷達圖網頁 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
今天我們要來製作一個可以掃描出敵人的動態雷達圖,讓隱藏在地圖深處的隨機敵人現身。本次工具主要透過 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

這篇文章 【Canvas創作教學】畫個偵測敵人的動態雷達圖網頁 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
來用可怕的三角函數做網頁吧! -Part 2科幻時鐘(直播筆記) https://creativecoding.in/2021/05/14/%e4%be%86%e7%94%a8%e5%8f%af%e6%80%95%e7%9a%84%e4%b8%89%e8%a7%92%e5%87%bd%e6%95%b8%e5%81%9a%e7%b6%b2%e9%a0%81%e5%90%a7-part2-%e7%a7%91%e5%b9%bb%e6%99%82%e9%90%98/ Fri, 14 May 2021 02:16:00 +0000 https://creativecoding.in/?p=757 上一篇中,老闆利用三角函數幫 DOM 做 CSS 的絕對定位,這次我們雖然也是使用三角函數,但是改使用 canvas 在網頁上繪圖,製作科幻效果的時鐘。

這篇文章 來用可怕的三角函數做網頁吧! -Part 2科幻時鐘(直播筆記) 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
利用三角函數與 canvas 製作科幻感時鐘
利用三角函數與 canvas 製作科幻感時鐘

本文翻自[週四寫程式系列] 來用可怕的三角函數做網頁吧! – Part2直播影片,對文章內容有疑問,或是想要老闆手把手帶你飛,都可以觀看影片詳細內容。

上次老闆帶大家利用三角函數製作衛星繞月球,還沒自己動手操作的同學,可以回上一篇挑戰,複習三角函數如何結合到網頁中。雖然數學看起來很可怕,但是它卻可以協助你製作有趣的效果,而數學中的表達式在各個地方都通用,不管是製作特效、資料分析,都可以讓你優雅地描述一件事的方式。

這次要製作的科幻效果,使用 FUI 風格,FUI 設計有許多不同的全名 (fake user interface, fictional user interface, fake user interface, 虛構使用者介面),這些 f 開頭的單字,代表還不存在的人機互動介面,常見於科幻電影場景中主角操作的機器介面,或是鋼鐵人 AI 介面…等,透過數據運算組成各種元件,讓畫面看起來科技感十足。

前情提要

開始製作前,老闆這邊提供這次範例的成果網址,讓大家在實作時可以參考。

上一篇中,老闆利用三角函數幫 DOM 做 CSS 的絕對定位,這次我們雖然也是使用三角函數,但是改使用 canvas 在網頁上繪圖。首先,讓我們快速回顧上一篇中提到的,應用三角函數幫 DOM 做絕對定位,讓遊戲元件在正確位置顯示,以砲台射擊遊戲為例,假設右上方有個目標物,我們要讓射出的砲彈的角度一直更新,才能能夠射到目標物,而每段時間砲彈要前進多少距離,則透過三角函數取得每次要調整的距離 Δy, Δx。

<canvas> 是一個 HTML 元素,我們可以利用程式在這個元素上繪圖(通常是用 JavaScript)- MDN

那麼又該如何知道 Δy, Δx 是多少呢?我們知道常見的三角形(30°, 45°, 60°)各邊的比例,但是當不是這些常見的角度時,就束手無策了。這時候我們就要感謝偉大數學家們的努力,只要知道 r 和 θ,就可以將 Δy 與 Δx 用三角函數去換算成 r * sinθ 與 r * cosθ。

數學家也從不同角度的三角形中發現,sinθ 與 cosθ 是一個規律的波浪圖,這代表著 r, Δy 與 Δx 值是成比例的關係。有了這些知識,就能開始著手製作 FUI 風格的時鐘了。以上我們帶大家快速回顧,要看更詳細的三角函數解說,可以參考 Part 1 影片和文章

畫個圓形吧

畫圖前,老闆先帶大家理解畫圓的觀念如下:從三角形、四角形,慢慢到多邊形,當與中心點距離一樣的點數亮夠多,視覺上就會像是畫出一個圓。首先,我們將中心點作為基準,每一次畫出的點與 r 的距離都相同,以 x 軸上的點做為起始點,將一圈 360° 均分為三點時,可以畫出三角形(左圖),四點的時候是四角形(右圖),以此類推,當點的數量夠多的時候就趨近圓形。

該如何將前面的這段敘述用程式寫出來呢?我們可以理解成將 360° 分成特定的份數,在座標軸上右邊 0° 是起始點,逆時針旋轉增加角度,假想有一個固定單位半徑 r 的圓在這個座標軸上,我們要畫出三角形,首先先將 360° 分成三等份,從 0° 的位置先點上一點,逆時針轉 120° 畫一點,最後再逆時針旋轉 120° 畫一點,三個點互相連接後,就成為了三角形,其他多邊形都能用這種方式去畫。聰明的大家這時候就可以發現,如果這個圓上有無限多個點,連接起來之後就趨近圓形。

那麼又該如何算出下圖中點 2 的座標呢?假設今天我們要畫一個 n 邊形,先將圓平分成 n 個點後,每次增加的角度是 360°/n,第 i 個點的角度就是 i *(360° / n)。用前面提到的三角形來做檢驗,第一個點就是 1 * (360° / 3) = 120°,第二個點就是 2 * (360° / 3) = 240°,第三個點就是 3 * (360° / 3) = 360°,也就回到了原點。第二點的座標算法為:x 座標 r * cosθ,y 則是 r * sinθ,其他點的座標算法一樣。接下來,就讓大家跟著老闆開始畫圖吧。

動手做

在我們要開始動手做之前,有幾個重點:

  1. 不能使用 canvas 中畫圓的函數。
  2. 因為電腦無法解讀 ” 從中心畫線到 30° 的位置 ” 這種口語的陳述,所以我們要去換算每個點的 x 與 y 的座標,並將所需要的點連線,才能畫出我們要的圖。

畫圖

理解畫圓的思維邏輯後,先跟大家介紹網頁畫圖的技術,在網頁畫圖我們會使用 canvas 來畫圖,畫圖的方式跟網頁定義 DOM 位置的不同,需要定義每個點的位置,點相連之後畫出我們需要的圖。

開始畫圖前,大家可以先將 codepen 的開發環境以及程式碼設置成跟老闆一樣,避免操作上的出入:

  • html 使用 pug
  • css 使用 sass
  • js 區塊將 jQuery 引用進來

HTML

canvas#myCanvas

CSS(Sass)

html, body
  width: 100vw
  height: 100vh
  margin: 0
  padding: 0
  overflow: hidden
  background-color: #000
canvas
  background-color: #fff

當我們把上面程式碼輸入後,會發現 canvas 的區塊沒有撐滿整個螢幕(下圖白色部分,這邊設定成白色背景是方便大家了解 canvas 的變化,之後在第5步驟開始畫方塊時,會將 canvas 的背景色拿掉)。

該怎麼調整 canvas 畫布跟螢幕一樣呢?我們在 js 區塊中撰寫以下程式碼,步驟如下:

  1. 利用 id 取得畫布的 DOM
  2. 透過取得的 canvas 來取得繪圖元件
  3. 設定畫布的寬高
    我們希望 canvas 可以撐開整個畫面,透過 outerWidth() 以及 outerHeight() 取得視窗的寬高,並將 canvas 畫布的寬高設定成這些值
    JavaScript
// 1. 取得 canvas 畫布
var c = document.getElementById('myCanvas');
// 2. 畫布繪圖,後面開始繪圖才會使用到這個變數
var ctx = c.getContext("2d");

// 3. canvas 畫布設定
var ww = $(window).outerWidth();
var hh = $(window).outerHeight();
// 3. 將螢幕寬高指定給畫布
c.width = ww;
c.height = hh;

4. 更新畫布大小

這時候若是調整螢幕大小,也就是網頁的寬高改變,會發現畫布沒有跟著撐滿版,所以我們必須監聽畫面的 resize,當畫面縮放的時候,動態地抓取當下畫面的寬高並重新設定 canvas 畫布。所以我們將程式碼調整成:

var c = document.getElementById("myCanvas");
var ctx = c.getContext("2d");

var ww = $(window).outerWidth();
var wh = $(window).outerHeight();

// 將重複使用的程式碼整理成 function
function getWindowSize() {
  ww = $(window).outerWidth();
  wh = $(window).outerHeight();
  c.width = ww;
  c.height = wh;
}

// 進入網頁後先做一次畫布大小調整
getWindowSize();
// 監聽畫面 resize
$(window).resize(getWindowSize);

5. 開始畫圖 – 方塊

要注意在canvas 中畫圖比較特別的是,我們要定義兩點 a 和 b 才能連成線;若是要畫出一個方塊,可以直接透過 canvas 內的 api 拉出方塊後填色,讓我們利用以下幾個語法,畫出位於 (0, 0) 寬度 50px 的正方形:

  • ctx.fillStyle = “white” 填色顏色為白色
  • ctx.beginPath() 開始繪圖
  • ctx.rect(x, y, width, height) 在座標 (x, y) 上畫一個寬 width 高 height 的矩形
  • ctx.fill() 填色
function draw(){
  ctx.fillStyle = "white";
  ctx.beginPath();
  ctx.rect(0, 0, 50, 50);
  ctx.fill();
}

draw();

6. 隨時更新畫布

我們想讓正方形隨著時間往右移動,所以多加了一個時間變數 time,讓 draw function 被呼叫的時候增加 time 的值,並往右移動,所以我們將原本座標 x 跟著時間改變,就產生出一個白色方塊一直向右移動。

var time = 0;
function draw(){
  ctx.fillStyle = "white";
  ctx.beginPath();
  ctx.rect(time, 0, 50, 50);
  ctx.fill();
  time+=1;
}

setInterval(draw, 10);

7. 清空畫布再更新

這時候我們可以發現,方塊是往右了,但是卻變成一條白色的線,那是因為每次畫上新的方塊時,畫布沒有清空。所以我們在每次要畫方塊前,都先畫一個覆蓋整個畫面的長方形,將原本的畫面蓋掉後,再更新正方形。 若是覆蓋的長方形是半透明的會發生什麼事?沒錯!正方形移動時就會產生殘影,大家可以嘗試不同數值試試看。

...
var time = 0;
function draw(){
	ctx.fillStyle = "rgba(0, 0, 0, 0.05)";
  ctx.beginPath();
  ctx.rect(0, 0, ww , wh);
  ctx.fill();

  ctx.fillStyle = "white";
  ctx.beginPath();
  ctx.rect(time, 0, 50, 50);
  ctx.fill();
	time+=1;
}

setInterval(draw, 10);

座標軸基礎設置

透過以上操作,讓大家了解繪圖的基本操作並讓方塊順利移動後,接著準備進入正題。首先我們要先繪製座標軸,但以網頁來說,左上角為坐標(0,0),而這次專案中我們希望座標軸的中心點(0, 0) 改在正中央,所以我們需要將中心點開始移動。

  1. 使用中心點作為初始值

要達成這個目標,我們直覺地想到把方塊移到中心,所以我們多了一組變數來記錄整個螢幕的中心點,並將這個中心點加到白色方塊的初始位置上。這邊還要注意的是,當螢幕 resize 時,center 值也必須更換,總共有以下三個部分的程式碼要調整。

// 多一組變數記錄中心點
var center = {
  x: 0,
  y: 0
};

function getWindowSize() {
  ww = $(window).outerWidth();
  wh = $(window).outerHeight();
  c.width = ww;
  c.height = wh;
  // 重新計算中心點
  center.x = ww/2;
  center.y = wh/2;
}

function draw(){
  ctx.fillStyle = "rgba(0, 0, 0, 0.05)";
  ctx.beginPath();
  ctx.rect(0, 0, ww, wh);
  ctx.fill();
  
  ctx.fillStyle = "white";
  ctx.beginPath();
  // 每次繪製方形的座標時要加上 center 值
  ctx.rect(time + center.x, 0 + center.y, 50, 50);
  ctx.fill();
  time+=1;
}

2. 更改畫布中心

如果我們每次要繪製任何東西時,都要手動加上這個 center 的值,非常地不方便,甚至還有可能遺漏它。所以這時候我們可以使用 canvas 內建的功能,直接改變畫布位置,就不用每次繪製時都要在該元件多加上偏移的座標。這邊我們將 getWindowSize 調整成:

function getWindowSize() {
  ww = $(window).outerWidth();
  wh = $(window).outerHeight();
  c.width = ww;
  c.height = wh;
  
  center.x = ww/2;
  center.y = wh/2;
  
  ctx.restore();
  // 移動畫布座標
  ctx.translate(center.x, center.y);
}

3. 調整 draw()

完成更改畫布中心之後會發現,方塊沒出現在畫面裡,因為我們除了調整中心的畫布之外,也要將方塊的初始值調整回來。大家可以試著調整 draw 中清畫布的顏色,會發現這塊清除的色塊並沒有覆蓋整個版面,也是從中心點往右下長一個方塊,所以這塊清除的畫布的位置也需要調整。

function draw(){
  // 清畫布方塊顏色
  ctx.fillStyle = "rgba(0, 0, 0, 0.05)";
  ctx.beginPath();
  // 調整清除畫布的初始位置
  ctx.rect(-center.x, -center.y, ww , wh);
  ctx.fill();
  
  ctx.fillStyle = "white";
  ctx.beginPath();
  // 白色方塊初始值
  ctx.rect(time, 0, 50, 50);
  ctx.fill();
  time+=1;
}

座標軸

透過白色方塊了解 canvas 繪圖和畫布座標移動後,總算要進入正題。

我們首先先繪製靜態的畫面,第一個就是我們的座標軸,比較不一樣的是,剛剛都是直接畫一個形狀填色,這次要改成用點連出我們需要的線並填色。會用到的程式碼如下:

  • ctx.stokeStyle = ‘rgba(255, 255, 255, 0.05)’ 畫筆顏色
  • ctx.strokeWidth = 1 畫筆粗度
  • ctx.moveTo(x, y) 將畫筆移到 (x, y)
  • ctx.lineTo(x2, y2) 從上一個位置拉一條直線到 (x2, y2)
  • ctx.stroke() 畫圖
function draw(){
  ...
  
  // 座標軸
  ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)';
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(-ww/2, 0);
  ctx.lineTo(ww/2, 0);
  ctx.moveTo(0, -wh/2);
  ctx.lineTo(0, wh/2);
  ctx.stroke();
  
  ...
}

從方形到圓形

有了座標軸後,我們要在上面畫一個圓形,這邊老闆一樣帶大家從四角形開始理解,了解四角形後,只要畫的點增加到一定數量,繪製出來的多邊形就會趨近於圓形。

我們嘗試畫個半徑為 200 的四邊形,首先第一個點為右邊,要注意的是,網頁的座標跟大家認知上的座標 y 軸方向是相反的,往下是正數,所以第二點是下面,第三個點是左邊,逆時針旋轉,以此類推。

另外要注意的是,網頁中畫圓是用弧度來計算,一圈 360° 等於 2PI,所以我們設一個變數來把角度轉換成弧度,若是沒有用弧度繪圖,就會出現右邊圖裡的窘境。這邊大家也可以試試看,當 n 增加到一定的量,畫出的圖就會趨近於圓形。

function draw(){
  ...
  // 每個點與中心的距離
  var r = 200;
  // 角度轉換成弧度
  var deg_to_pi = Math.PI * 2 / 360 ;
  // 畫 4 個點
  var n = 4;
  ctx.beginPath();
  for(var i = 0; i<=n; i++){
    var deg = i * (360 / n) * deg_to_pi;
    ctx.lineTo(
      r * Math.cos(deg),
      r * Math.sin(deg)
    );
  }
  ctx.stroke();
  ...
}

波浪線

那要怎麼畫旋轉的波浪圓形呢?我們可以觀察到,這個波浪圓形上的每一個點,只是在做半徑的調整,所以我們可以在 r 上面做些手腳,讓它成為一個動態的半徑,就能完成波浪的圓形,至於要如何調整才能讓這個點是在一定的範圍內變動,大家是不是想到 sin 和 cos 的圖了呢?沒錯,只要利用它們和 r 做組合,就可以調整出不一樣的波浪圖。老闆這邊提示大家:

  1. 因為 sin 和 cos 的最大到最小值分別是 1, -1,所以可以多乘上一個數,來增加波浪起伏
  2. 角度變化的越快就會讓波讓越密集,所以在代入 sin 的角度中,也多乘上一個數
  3. 波浪沒有完整的接合起來,是因為沒有把角度完整走完一個圓,一個完整的圓角度是 2PI,只要再多乘上 2PI 運算即可。

大家可以嘗試看看不同數值乘線的效果,那又該怎麼讓波浪圓形動起來呢?這邊我們要在 now_r 變數中的角度部份加入會一直變動的數值,才有辦法讓半徑多加上這個 1 單位到 -1 單位的動態半徑,我們可以觀察到, time 會隨著時間一值增加,只要活用 time 就能讓波浪圓形動起來。

function draw () {
  ...
  // 波浪圓形
  var deg_to_pi = Math.PI / 360 * 2;
  // 基礎半徑值 200
  var r = 200;
  // 200 個點的多邊形
  var n = 200;
  ctx.beginPath();
  for(var i = 0; i<=n; i++){
    // 2 * sin() 增加動態半徑的 range
    // 2 * Math.PI 確保畫出完整的圓
    var now_r = r + 2 * Math.sin(2 * Math.PI *  i / 10 + time / 20);
    var deg = i * (360 / n) * deg_to_pi;
    ctx.lineTo(
      now_r * Math.cos(deg),
      now_r * Math.sin(deg)
    );
  }
  ctx.stroke();
  ...
}

刻度

畫刻度的想法又跟畫圓形不太一樣,兩者都需要使用到角度來輔助畫圖,波浪是每畫一點就改變角度,而畫刻度不一樣的地方在於,是固定角度在 r 畫上一點後,改變 r 的長度後再畫一點連起來,完成一個角度後再到下一個角度用相同方式繪製線。

以下圖為例子,假設我們想畫一個角度 θ ,從半徑 3 連到半徑 5 的線,則先將畫筆移到(3cosθ, 3sinθ),再連線到 ((3+2)*cosθ, (3+2)*sinθ)。用基本的 r 加上某個數值,成為下一個要連線的點,這樣做的好處是,如果今天我們想變成比較長的刻度,只要調整加上的值就可以達成。

我們要畫出的圖需求如下:

  1. 畫一圈半徑為 220 共 240 個刻度所繞出的圓。
  2. 每隔10個刻度,就會有一條中間長度的刻度
  3. 上中下右(也就是每隔 240/4 = 60)有一條最長長度的刻度,

總共有三種樣式的刻度,透過三元運算子,可以先將其中一段拆解出來看,活用三元運算子就能用刻度來組出不同的長度,以下面這個三元運算子解讀的方式如下,當判斷式為 true 時,會執行前者 true 的內容,否則就執行後者 false。

判斷式 ? true : false

所以「當 i 除以 10 的餘數為 0 的時候則會使用 4,否則就使用 0」的三元運算子應該這樣寫:

i % 10 == 0 ? 4 : 0

同樣的,我們也可以將透明度結合三元運算子,在畫線前,賦予畫筆顏色不同透明度。接下來就可以設定兩個點(start_r 到 end_r)與角度,記得角度要乘上前面宣告的 deg_to_pi。

萬事俱備,就能開始繪圖了,一樣使用前面提到的 beginPath(), moveTo(), lineTo(), stroke() 來進行繪製。

var r = 220;
var count = 240;
for(var i=0; i<=count; i++){
  // len 為不同刻度有不同長度的增加量
  var len = 4 + (i % 10 == 0 ? 4 : 0) + (i % 60 == 0? 8 : 0);
  var opacity = (len > 4 ? 1 : 0.5);
  var start_r = r;
  var end_r = start_r + len;
  //最基本的角度分佈
  var deg = 360*(i/count)*deg_to_pi;
  ctx.beginPath();
  ctx.moveTo(
    start_r * Math.cos(deg),
    start_r * Math.sin(deg)
  );
  ctx.lineTo(
    end_r * Math.cos(deg),
    end_r * Math.sin(deg)
  );

  // 決定畫筆樣式
  ctx.lineWidth = 1;
  ctx.strokeStyle = "rgba(255, 255, 255, "+opacity+")";

  ctx.stroke();
}

外圈

外圈的作法很簡單,與剛剛畫內圈的方式雷同,調整 r 以及減少點(count)的數量就可以達成,大家可以按照喜好嘗試調整看看。

function draw () {
  ...
  var r = 400;
  // 共畫 60 個刻度
  var count = 60;
  for (var i = 0; i <= count; i++) {
    // 上中下右刻度要不一樣,等於是每十五個刻度就要不同長
    var len = 4 + (i % 15 == 0 ? 8 : 0);
    var opacity = len > 4 ? 1 : 0.5;
    var start_r = r;
    var end_r = start_r + len;
    var deg = 360 * (i / count) * deg_to_pi;
    ctx.beginPath();
    ctx.moveTo(start_r * Math.cos(deg), start_r * Math.sin(deg));
    ctx.lineTo(end_r * Math.cos(deg), end_r * Math.sin(deg));

    ctx.lineWidth = 1;
    ctx.strokeStyle = "rgba(255, 255, 255, " + opacity + ")";
    ctx.stroke();
  }
  ...
}

秒針、分針、時針

要畫秒針、分真或時針,我們需要知道目前時間,獲得目前時間的方式會用到以下程式,我們也利用 jQuery,將時間放在畫面上,方便我們在製作時能夠參照畫面是否和我們期望的相同。

HTML

canvas#myCanvas
.time +00:00:00

CSS(Sass)

canvas
  transform: scaleY(-1)

JavaScript

var nowTime = new Date();
var sec = nowTime.getSeconds();
var min = nowTime.getMinutes();
var hour = nowTime.getHours();
  
$('.time').text('+00:'+hour+':'+min+':'+sec);

先用固定的角度來測試畫出來的圖形是否跟真實的時鐘一樣,我們傳入 45 作為角度參數後發現一些問題,跟著下面的步驟去微調就能解決:

  1. 將角度多乘上畫圓的角度:畫出來的角度不是 45 度。只要跟前面一樣,將角度轉換成畫圓要用的角度即可。
  2. 從正上方旋轉 45 度:給 45 度為什麼是指向右下呢?我們期望會從正上方作為0度開始旋轉,所以 45 度應該要指著右上方,大家還記得在上一篇有提到,網頁中的座標系 y 軸向下才是正值(左圖),老闆這邊使用偷吃步的方式,只要修改畫布將整張畫布垂直翻轉,45度指的方向就對了。
  3. 讓秒針跟著時間旋轉:緊接著,將角度位置改使用 sec 變數讓秒針動起來。對於秒針而言一圈有 60 個刻度,我們先將 0 到 60 個刻度換算成 0 到 1,再換算 0 到 360,所以就等於 sec / 60 * 360,就是秒針每秒要改變的角度。
  4. 調整旋轉方向:我們可以看到現在以逆時針旋轉,所以我們多乘以一個負值,讓旋轉的方向變成順時針轉的方向。
  5. 補上初始值:最後我們察覺到正確的秒針位置總是少了90度,這就是因為我們現在的座標系初始值 0 度是在右邊,只要將初始值的0換到正上方,這邊我們在函式轉換的角度位置中加上初始值(90度)即可。
function drawPointer(r, deg, lineWidth) {
  // 決定畫筆
  ctx.lineWidth = lineWidth;
  ctx.strokeStyle = "rgba(255, 255, 255, .5)";
	
  // 調整角度初始值與轉化成圓的角度
  var now_deg = (deg + 90) * deg_to_pi;
    
  ctx.beginPath();
  ctx.moveTo(0,0);
  ctx.lineTo(r * Math.cos(now_deg), r * Math.sin(now_deg));

  ctx.stroke();
}

// 秒針分針時針畫法
drawPointer(400, -360 * (sec / 60), 1);
drawPointer(210, -360 * (min / 60), 1);
drawPointer(150, -360 * ((hour + min / 60) / 12 ), 4);

要注意的是,時針畫法比較不一樣,我們會希望時針不是單純指在對應的數字上,還要多加上過了幾分鐘的變化,所以先將過了幾分鐘除以 60,獲得目前經過一小時中的幾分鐘後,再加上小時除以12,因為一圈只有12個刻度,就能獲得目前的度數。

結語

老闆這邊附上這次範例的成果網址,讓大家在實作時可以參考。

這邊讓我們再複習一次整個製作過程:

  1. canvas 網頁繪圖:若是要讓畫布和螢幕同樣大小,要監聽 resize 並重新調整畫布大小。
  2. canvas 動態效果:相當於在畫布上一直重新繪圖,要記得清空畫布後再畫上新東西。
  3. 調整 canvas 座標:canvas 預設左上角為 (0, 0),可以利用 ctx.translate(x, y)調整畫布的位置。
  4. 繪製座標軸。
  5. 從四角形到圓形:利用三角函數來畫出不同形狀,當點的數量足夠時,連起來就會像是圓形,要注意要將角度換算成弧度。
  6. 會動的波浪圓形:在畫圓的時候,透過三角函數來製造波浪,並結合時間就能讓波浪圓動起來。
  7. 繪製刻度:這次範例中的內圈與外圈都是用這種方是去完成,在不同角度上點出兩個點連起來就變成刻度,結合三元運算子就能在不同角度時有不同的樣式。
  8. 繪製秒針、分針與時針:類似刻度的畫法,只是這次第一個點是圓心,再將時分秒換算成角度後畫出第二點並連線。

數學裡面有許多奇怪的東西,但也感謝數學家們的努力,讓我們可以應用在遊戲或動態特效等地方。在第一篇我們用三角函數來幫 DOM 做定位,這一篇則是在 canvas 上繪圖,並適時結合三角函數,產出許多有趣的效果,各位同學挑戰完兩篇三角函數教學之後,可以回頭思考看看兩者的差異,期待大家能利用三角函數做出更多有趣的效果。

工商時間:老闆在 Hahow 有一堂課程 – 動畫網頁特效入門,裡面有一些數學的內容,誘使大家跳坑,一起去學這些恐怖的東西,老闆已經努力將課程講解得有趣點,讓大家在比較沒有壓力的狀況下學習這些數學。(笑

此篇直播筆記由幫手 H 協助整理

墨雨設計banner

訂閱 Creative Coding Taiwan 電子報:

這篇文章 來用可怕的三角函數做網頁吧! -Part 2科幻時鐘(直播筆記) 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
老闆的網頁實驗室#2-實作 Canvas 遮罩動畫 https://creativecoding.in/2020/03/06/%e8%80%81%e9%97%86%e7%9a%84%e7%b6%b2%e9%a0%81%e5%af%a6%e9%a9%97%e5%ae%a42%ef%bc%8d%e5%af%a6%e4%bd%9c-canvas-%e9%81%ae%e7%bd%a9%e5%8b%95%e7%95%ab/ Thu, 05 Mar 2020 17:47:27 +0000 https://creativecoding.monoame.com/?p=95 案例解析 Louis Ansa — Portfolio 這次要分析的是在法國的設計師 Louis Ansa 的作品集網頁,在 Louis Ansa 的網頁中開始的載入與關於頁面都有出現的遮罩效果,讓老…

這篇文章 老闆的網頁實驗室#2-實作 Canvas 遮罩動畫 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
案例解析

Louis Ansa — Portfolio

這次要分析的是在法國的設計師 Louis Ansa 的作品集網頁,在 Louis Ansa 的網頁中開始的載入與關於頁面都有出現的遮罩效果,讓老闆帶著大家來看看這是如何實現的吧!

分析思路

這一頁的動畫主要有三個部分組成:

  1. 球型遮罩:在圓形裡面的文字會呈現不同的背景色與內文顏色
  2. 移動的球體:兩個球體的圓心沿著畫面的中心做圓周運動
  3. 變動的球體形狀:球體的邊界呈現不規則波動

首先針對1.的部分,要改變特定區域內的顏色效果,依據需要達成的效果不同,我們可以直接選擇改變區域內的顏色內容;或是使用兩層圖片,再將區域內不需要的上層元素移除掉。

如果只需要處理顏色的變換,沒有非常複雜的動畫,可以使用 css 的 mix-blend-mode 屬性來實現,mix-blend-mode 提供了saturationhuedifference等條件直接處理顏色。但是考量到後續如果需要做出多個物件、比較複雜的變形以及效能問題,從 Canvas 下手就會更靈活。

實作

1. 創建兩層 canvas

首先先使用兩層的canvas,並做出完全相同的長寬、內文、行高與字體大小的圖片:


canvas.draw = function() {
  ctx = this.ctx;
  requestAnimationFrame(() => {
   this.draw(ctx);
  });
ctx.fillStyle = this.backgroundColor;
  ctx.fillRect(0, 0, window.innerWidth, window.innerHeight);
  ctx.beginPath();
  ctx.fillStyle = this.fillStyle;
  ctx.lineWidth = 5;
  ctx.font = "bold 100px Montserrat";
  // 這邊先給出一個 text 的變數是用來測量行高,以便換行書寫、定位圖形中心
  text = "Monoame";
  var textWidth = ctx.measureText(text).width;
  var textHeight = parseInt(ctx.font.match(/\d+/), 10);
  ctx.fillText("Monoame", this.cx - textWidth / 2, this.cy - textHeight / 2);
  ctx.fillText("Studio", this.cx - textWidth / 2, this.cy + textHeight / 2);
 };
底層(透過遮罩看到)的背景與文字顏色
上層的背景與文字顏色

2. 設定遮罩圖形與透視的效果

這個步驟是整個案例的核心,我們使用到 canvas 的 globalCompositeOperation屬性,globalCompositeOperation可以指定 canvas 針對當前繪製圖形與背景的交互效果。

舉例來說,預設的值是 source-over即是直接覆蓋過背景的圖層,畫上新的路徑;而我們使用到的是 destination-out,可以將新舊圖形重疊的區域設定為透明,只在沒有重疊的的部分畫出圖形。

source-over 預設值。將新圖形畫在舊圖形之上。
destination-out
只保留新、舊圖形非重疊的舊圖形區域,其餘皆變為透明。

左圖片中,藍色方形是背景的原始圖形,紅色圓形是新繪製的圖形,我們可以比較一下兩種形式對於背景圖形的影響。關於 globalCompositeOperation的更多選項與說明可以參考 MDN 的 Canvas 教學

實作中先在上層的 canvas 中繪製出作為遮罩的圓形,並在繪製圓形之前將 ctx.globalCompositeOperation設定為"destination-out",如此一來,在這個圓形的範圍內,原本被上層遮住的黑底紅字的底層就會顯示出來。最後也別忘了,在繪製完遮罩的部分之後將參數設定回原本的 "source-over",這樣第一個部分就大功告成了!

ctx.globalCompositeOperation = "destination-out";
ctx.arc(mousePos.x, mousePos.y, this.r, 0, Math.PI * 2, false);
ctx.fill();
ctx.globalCompositeOperation = "source-over";
只有在圓形區域內的上層會顯示為透明

3. 創建圍繞著圖片中心轉動的動態效果

有了圓形遮罩之後,我們要如何讓他繞著圖形的中心點做圓周運動呢?

這裡我們可以活用 canvas 的 translate 與 rotate 方法,在每一次渲染的時候,先使用rotate 將畫布旋轉 1 度,之後再使用translate移動到遮罩的圓心位置,這樣就可以創造出像是衛星環繞的圓周運動效果了。這裡有個小地方要注意,就是我們在移動圍繞的中心點跟旋轉畫布之前,要先用 ctx.save()將目前的畫布資訊存起來,等到繪製完成之後,再使用 ctx.restore() 回復到旋轉跟移動之前的位置,才不會影響到其他部分的圖形繪製喔。

在每一禎的圖片的繪製中,我們都會先旋轉畫布,再將座標移動到遮罩的圓心

ctx.clearRect(0, 0, canvas.width, canvas.height);
angle = (angle+1) % 360;

ctx.save();
ctx.translate(centerX, centerY);
ctx.rotate(2* Math.PI* angle/360);
ctx.beginPath();
ctx.arc(0, 0, dotR, 0, 2 * Math.PI, false);
ctx.fillStyle = "#000000";
ctx.closePath();
ctx.fill();

ctx.translate(100, 0);

ctx.fillStyle = "#FF0000";
ctx.beginPath();
ctx.arc(0, 0, circleR, 0, 2 * Math.PI, false);
ctx.closePath();
ctx.fill();

ctx.restore();

旋轉的部分可以參考這個範例:

See the Pen Canvas rotate around point by Ankycheng (@ankycheng) on CodePen.

登愣,老闆上菜啦!

結合以上的步驟,最後的成品就是這樣:

See the Pen canvas mask effect by Ankycheng (@ankycheng) on CodePen.

這個案例使用遮罩加上簡單的動態實現靈活變動的效果,關於遮罩的應用還有很多,像是這個案例就使用了一樣的 canvas 特性做出類似刮刮卡的效果:https://codepen.io/dudleystorey/pen/yJQxLX。而形狀的部分,除了使用單純的圓形,我們也可以模擬原版中抖動的邊框,或是不同形狀的靈活變化。

有什麼有趣的想法都歡迎在留言告訴老闆,或是你覺得這樣的特性還有哪些可以靈活運用的地方呢?如果這篇文章超過 15 個留言的話,老闆將會進一步解密如何做出原本中華麗的動態球體!老闆的網頁實驗室,我們下回見囉!

手刀訂閱老闆來點寇汀吧!讓老闆帶你拆解更多有趣的程式案例 👨‍🍳

參考資料:

CSS: mix-blend-mode
Canvas: globalCompositeOperation

課程推薦

老闆在Hahow好學校開了與互動網頁有關的兩門課,其中,動畫互動網頁程式入門(HTML/CSS/JS)以簡單例子帶你入門網站的基礎架構及開發,用素材刻出簡單有趣又美觀的網頁和動畫,享受做出獨一無二的網頁所帶來的成就感,在職場上與設計師和工程師合作無間。

打好基本的互動網頁基礎之後,可以進階動畫互動網頁特效入門(JS/CANVAS),紮實掌握JavaScript 程式與動畫基礎以及進階互動,整合應用掌控資料與顯示的Vue.js前端框架,完成具有設計感的互動網站。期待在課程裡見到你!

墨雨設計banner

訂閱 Creative Coding Taiwan 電子報:

這篇文章 老闆的網頁實驗室#2-實作 Canvas 遮罩動畫 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>