很多的讀者經常使用像是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 協助整理




