很多的讀者經常使用像是canvas這樣的函式庫,東西已經包裝成物件,可以輕鬆的拖曳、偵測點擊。這次的老闆週四寫程式,要來教大家在不使用函示庫的情況下,直接使用純繪圖的canvas生成物件,並偵測事件,來用滑鼠點選跟拖曳物件。
目標
這篇文章將會讓你學到:
- 向量的基礎知識
- 把純繪圖的canvas包成物件的架構
- 利用物件導向完成元件與滑鼠的基礎互動
架構
此次影片要實作圖形使用者介面(Graphical User Interface,本篇後以GUI稱之)作為小畫家的操作介面。GUI是採用圖形方式顯示的使用者介面,讓使用者可以使用滑鼠或相關設備操縱螢幕上的圖標或菜單選項,跟早期的電腦使用命令列介面相比,讓使用者在視覺上更容易接受。
實作GUI前,可以先了解一下物件設計的架構:
- 成品中的每個方塊都是一個GUI object
- 而所有的方塊可以形成一個GUI group
- 最後再交由GUI scene 畫出整個場景
了解向量
在畫布的操作中,位置是很重要的一環,位置通常由座標(通常是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() }
物件與滑鼠互動
完成物件後,我們最後要加入滑鼠與物件的幾種互動。
(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 的你有一些幫助。
再次快速總結步驟:
- 利用GUI Object的概念,快速畫出多個物件
- 加上事件偵測,讓滑鼠位置與物件互動,達成亮度的提示
- 加上滑鼠按鍵的事件偵測,讓滑鼠拖移物件
看著框框跟隨著滑鼠的移動,真的很有成就感啊!
老闆的工商時間
想了解更多如何寫出漂亮清晰的網頁嗎?老闆在 Hahow 的教學課程 動畫互動網頁程式入門(HTML/CSS/JS) 用平易近人的語言,用簡單的方式帶你作出不簡單的網頁。已經有網頁程式基礎了嗎?進階課程 動畫互動網頁特效入門(JS/CANVAS) 能讓你紮實掌握 JavaScript 程式與動畫基礎以及進階互動,整合應用掌控資料與顯示的Vue.js前端框架,完成具有設計感的互動網站。
此篇直播筆記由幫手 Y-Y-H 協助整理