互動網頁 彙整 | Creative Coding TW - 互動程式創作台灣站 https://creativecoding.in/tag/互動網頁/ 蒐集互動設計案例、教學與業界資源,幫助你一起進入互動程式創作的產業 Mon, 28 Nov 2022 00:18:02 +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 互動網頁 彙整 | Creative Coding TW - 互動程式創作台灣站 https://creativecoding.in/tag/互動網頁/ 32 32 【徵文賞-動態網頁】優選|使用前端技術製作自己的修圖軟體吧(Python + OpenCV + eel) – 林慶佳 https://creativecoding.in/2022/11/24/collection221110-web-3/ Thu, 24 Nov 2022 14:54:53 +0000 https://creativecoding.in/?p=3308 透過互動程式創作徵文賞,我們期望讓更多人認識並加入 Creative Coding 這個新奇有趣的領域。此作品為動態網頁組佳作,使用 Python, OpenCV, 和 eel 結合前端介面技術的幾個框架製作自己的修圖軟體。

這篇文章 【徵文賞-動態網頁】優選|使用前端技術製作自己的修圖軟體吧(Python + OpenCV + eel) – 林慶佳 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
前言

個人半學期的期末報告專案,因為想做的更好,所以自行研究了 Python 結合前端介面技術的幾個框架。

建議先備知識

  1. 前端開發經驗。
  2. Python 使用經驗。
  3. OpenCV 使用經驗。

最終成果預覽

Python UI 介面選擇

一開始課堂上是教預設的Qt軟體,但我覺得Qt醜醜的,所以忍不住去嘗試其他框架。

Tkinter

跟以前做window form很像,用程式碼掛載與編排UI物件。

from tkinter import *

root = Tk()
#創建label widget
myLabel = Label(root, text="Hello world! ")
# show on screen
myLabel.pack()

root.mainloop()

Qt Designer

在Anaconda預設功能, Anaconda資料夾下找到designer.exe啟動。

排版檔案會取為.ui附檔名,操作邏輯與Tk類似。

範例: 讀取圖片、變暗處理。

#CallMyWindow1.py
import cv2
import numpy as np
from PyQt5.QtGui import *
import sys
from PyQt5.QtWidgets import *
from Mywindow3 import *

class MyMainWindow(QMainWindow, Ui_MainWindow):
	def __init__(self, parent=None):
		super(MyMainWindow, self).__init__(parent)
		self.setupUi(self)
		self.loadBtn.clicked.connect(self.btnLoadfile_clicked)
		self.darkenBtn.clicked.connect(self.btnDarking_clicked)
       #讀取圖片 - 按鈕
	def btnLoadfile_clicked(self):
		global img
		img=cv2.imread("Baboon.bmp")
		imgBGR = cv2.resize(img, (300, 300), interpolation=cv2.INTER_CUBIC)
		imgRGB = cv2.cvtColor(imgBGR, cv2.COLOR_BGR2RGB)
		height, width = imgRGB.shape[:2]
		qimg = QImage(imgRGB, width, height, QImage.Format_RGB888)
		qpix = QPixmap.fromImage(qimg)
		self.img.setPixmap(qpix)
        #圖片變暗 - 按鈕
	def btnDarking_clicked(self):
               #........
		self.resImg.setPixmap(qpix)

if __name__=="__main__":
	app = QApplication(sys.argv)
	myWin = MyMainWindow()
	myWin.show()
	sys.exit(app.exec_())

Eel

讓使用者能用前端技術做UI的Python框架,內部使用Bottle Server框架,但不像flask能直接host在線上。

from turtle import position
import eel
import os
#eel.init('./web') #資料夾
eel.init(f'{os.path.dirname(os.path.realpath(__file__))}/web')
eel.start('main.html',size=(400,200),position=(500,500)) #網頁

#eel.start('main.html',mode ='chrome-app') #網頁 (app模式)

Flask / Django

使用Python編寫的輕量級Web應用框架。

範例: flask基本頁面,但其網頁模板概念跟Django雷同。

from flask import Flask
app= Flask(__name__);

@app.route('/')
def index():
    return "hello worldd123456"

if __name__ =='__main__':
    app.run(debug=True , use_reloader=False port=5000 , host='0.0.0.0')

範例: Django網頁

創個views.py檔案,裡面定義Hello頁面內容

from django.http import HttpResponse

def Hello(request):
    return HttpResponse("Hello world")

在urls.py定義Hello頁面的網址

from django.contrib import admin
from django.urls import path

#from django.conf.urls import url
from django.urls import include, re_path
from . import views

urlpatterns = [
    path('admin/', admin.site.urls),
    #url(r'^$' , views.Hello)
    re_path(r'^$' , views.Hello)  #re_path使用regex
]
框架名稱優點缺點
Tkinter像是用Python程式碼撰寫介面的Qt Designer。邏輯與QtDesigner類似,同樣無法解決程式碼攏長、難做排版等問題。
Flask/Django較完整且可部屬網頁的web應用框架。Python與前端邏輯溝通較複雜,需使用ajax等呼叫方式。
eel旨在於結合前端技術開發桌面應用程式,前後端邏輯串接容易。我試著包在docker環境下部屬但失敗多次,雖然文獻不多但似乎eel不支援網站部屬。

EEL基本使用

(推薦影片: https://youtu.be/FNPW2ZFksCQ)

python 呼叫js方法

python 方法上加@eel.expose 讓js端可以呼叫。

import eel
import os

@eel.expose
def print_js():
    print("Hi hi")


#eel.init('./web') #資料夾
eel.init(f'{os.path.dirname(os.path.realpath(__file__))}/web')
eel.start('main.html',mode ='chrome-app') #網頁 (app模式)

func.js檔案:

function hello(){
    eel.print_js()()
}

Async方法使用

import random
import eel
import os

@eel.expose
def getnum():
    return random.randint(0,100)

#eel.init('./web') #資料夾
eel.init(f'{os.path.dirname(os.path.realpath(__file__))}/web')
eel.start('main.html',mode ='chrome-app') #網頁 (app模式)


eel.sleep(5)
#time.sleep(5) 要sleep就用eel
async function doA(){
    var a=await eel.getnum()()
    alert(a)

}

按下按鈕後,js端等待python處理結果後出現alert。

<!DOCTYPE html>
<html>

<head>
    <title></title>
    <script src="/eel.js"></script>
    <link rel="stylesheet" href="style.css">
    <script src="func.js"></script>

</head>

<body>
    <h1>Hell world</h1>
    <button onclick="doA()">hello</button>
</body>

</html>

JS-EEL圖檔傳輸

跨語言開發首先遇到的問題便是如何傳遞圖片資訊? 由於處理後的圖片需轉換成前端html可顯示的based64編碼,其跟openCV imread讀取進來的圖片格式之間的關係又是如何? 為此我必須先了解從HTML的input 標籤輸入的圖片至python端之間的格式與轉換。我整理出如下圖這個流程:

(1.) 由input標籤開啟的圖片為大型二進位檔物件(Binary Large Object ,blob),代表了一個相當於檔案(原始資料)的不可變物件。

(2.) 需實作js的FileReader物件,將blob轉碼成based64字串。

(3.) 透過eel框架傳送字串至Python端。

(4.) 使用based64處理套件將字串解析成byte陣列。

(5.) 透過numpy將byte陣列轉成uint8陣列。

(6.) 透過cv2套件將uint陣列解碼成圖片,此時得到的檔案形同於imread進來的圖片。

(7.) 使用圖片資料進行影像處理操作。

(8.) 將處理完的圖片回傳至前端作顯示時,逆著遵循前述的轉碼步驟,先將圖片轉成byte陣列,再以based64編碼成字串透過eel框架溝通回前端,由js接收並設定img標籤的src。

JS讀取圖片

使用jQuery偵測input標籤Change事件,搭配fileReader轉成base64字串。

$("#img").on("change", function () {
  var file = this.files[0];
  const fr = new FileReader();
  fr.onload = function (e) {
    preview.src = e.target.result;
    currentFile = e.target.result;
    console.log(e.target.result);
  };

  if (file) {
    fr.readAsDataURL(file);
  } else {
    console.log("沒有檔案");
  }
  console.log(file);
});

Python接收圖片

將base64字串透過轉碼,轉成opencv可使用的格式。

def data_uri_to_cv2_img(uri):
     encoded_data = uri.split(',')[1]
    img = base64.b64decode(encoded_data)
    npimg = np.frombuffer(img, np.uint8)
    image = cv2.imdecode(npimg,cv2.IMREAD_COLOR)
    return image

搭配OpenCV

範例: 將圖片轉灰階

eel.expose(SetImg);
function SetImg(newimg) {
  preview.src = "data:image/png;base64," + newimg
}

function Gray() {
  currentFile = preview.src;
  eel.toGray(currentFile);
}
import base64
import random
from cv2 import imread
import eel
import os
import cv2
import numpy as np

def data_uri_to_cv2_img(uri):
     encoded_data = uri.split(',')[1]
    img = base64.b64decode(encoded_data)
    npimg = np.frombuffer(img, np.uint8)
    image = cv2.imdecode(npimg,cv2.IMREAD_COLOR)
    return image

@eel.expose
def toGray(url):
    img = data_uri_to_cv2_img(url)
    img=cv2.cvtColor(img,cv2.COLOR_BGR2GRAY)
    #TODO:.....回傳

eel.init(f'{os.path.dirname(os.path.realpath(__file__))}/web')
eel.start('main.html',mode ='chrome-app') #網頁 (app模式)

Python傳回圖片

def img_to_base64(img):
    b64_str = cv2.imencode('.png', img)[1].tobytes()
    blob = base64.b64encode(b64_str)
    blob = blob.decode('utf-8')
    return blob

直方圖

直方圖常常用在檢視圖片顏色趨勢、集中位置等等。 (一開始使用Chart.js插件)

js 端

// 直方圖
eel.expose(UpdateGarphData);
function UpdateGarphData(graphID , b,g,r) {
  var labs= [Array.from({ length: 255 }).map((currentElement, i) => i)]
  const ctx = document.getElementById(graphID).getContext("2d");

  const chart = new Chart(ctx, {
    type: "bar",
    data: {
      labels:labs[0],
      datasets: [
        {
          label: "b",
          data: b,
          backgroundColor: "blue",
        },
        {
          label: "g",
          data: g,
          backgroundColor: "green",
        },
        {
          label: "r",
          data: r,
          backgroundColor: "red",
        },
      ],
    },

  });
}
@eel.expose
def histGraph(url):
    img = data_uri_to_cv2_img(url)

    # 單色
    if img.ndim !=3:
        b=cv2.calcHist( [img], [0], None, [256], [0,256] )
        b= b.astype("float").flatten().tolist()

    # 彩色
    else:
        b=cv2.calcHist( [img], [0], None, [256], [0,256] )
        g=cv2.calcHist( [img], [1], None, [256], [0,256] )
        r=cv2.calcHist( [img], [2], None, [256], [0,256] )
        b= b.astype("float").flatten().tolist()
        g= g.astype("float").flatten().tolist()
        r= r.astype("float").flatten().tolist()

    eel.UpdateGarphData("histogram",b,g,r)()

注意np array無法直接傳輸,所以要轉成一般array。

瀏覽器無法像Python的numpy與的plot套件快速做統計與圖表,加上本身也不支援多線程(Multi-threading),因此當圖片解析度大,需要統計的數變多,前端直方圖生成便會延遲,除了採用異步執行避免程序卡死外,我還試過多個輕量圖表套件如Chart.js、dygraphs、EChart.js,尋找對資料最友善的解決方案,雖然目前前端直方圖生成仍有延遲存在,是日後需想辦法解決的問題(目前已是在Python生成直方圖數據,再傳輸至前端表現,理論上只需處理255*3個顏色資料,我認為會卡的點在於繪製與自動尋找最小至最大值區間)。

等化功能

等化是將像素顏色拉得平均一點。

@eel.expose
def equalizeHist(url):
    img = data_uri_to_cv2_img(url)
    g=img.copy()
    #單色
    if g.ndim!=3:
        g=cv2.equalizeHist( img );
    #彩色
    else:
        for i in range(0,3):
            g[:,:,i]=cv2.equalizeHist(img[:,:,i])

    histGraphByImg(g)
    blob = img_to_base64(g)
    eel.SetImg(blob)()

操作Hot Key

註冊document事件,並藉由array紀錄各步驟的base64字串。

const maxSaves=10;
const previousSaves = [];
const nextSaves = [];
//上一步
function BackToPreviouse() {
  if (previousSaves.length > 0) {
    base_str=previousSaves.pop();
    preview.src = base_str;
    SaveNextStep(base_str)
  }
}
//下一步
function BackToNext(){
  if (nextSaves.length > 0) {
    base_str=nextSaves.pop();
    SaveStep(base_str);
    preview.src = base_str;
  }
}
//紀錄步驟
function SaveStep(bs64){
  if(previousSaves.length<maxSaves){
    previousSaves.push(bs64);
  }
  else{
    //移除第一個
    previousSaves.shift();
    previousSaves.push(bs64);
  }
}
//紀錄返回上一步步驟
function SaveNextStep(bs64){
  if(nextSaves.length<maxSaves){
    nextSaves.push(bs64);
  }
  else{
    //移除第一個
    nextSaves.shift();
    nextSaves.push(bs64);
  }
}

// ***************** 註冊 hot key *********************


function RegiseterHotKey(e) {
  //上一步
  if (e.ctrlKey && e.key === 'z') {
    BackToPreviouse();
  }
  //下一步
  if (e.ctrlKey && e.key === 'x') {
    BackToNext();
  }
}
document.addEventListener('keyup', RegiseterHotKey, false);

// ***************** --- *********************
範例:上下一步熱鍵。

JS寫出圖片

源圖與輸出圖。
// Save as
async  function SaveAs() {
  const opts = {
    types: [
      {
        description: "Images",
        accept: {
          "image/*": [".png", ".gif", ".jpeg", ".jpg"],
        },
      },
    ],
    excludeAcceptAllOption: true,
    multiple: false,
  };

  //base64 轉 blob
  const url= decodeURIComponent(preview.src).split(",")[1];
  const imgBlob = Uint8Array.from(atob(url), c => c.charCodeAt(0));
  console.log(imgBlob);

  const newHandel = await window.showSaveFilePicker(opts);
   const writableStream = await newHandel.createWritable();

   // write our file
   await writableStream.write(imgBlob);

   // close the file and write the contents to disk.
   await writableStream.close();
}

拖拉圖片

//preview 畫布拖拉
$(function () {
  $("#preview").draggable();
});

//畫布滑鼠滾輪縮放
function zoom(event) {
  event.preventDefault();

  scale += event.deltaY * -0.02;

  // Restrict scale
  scale = Math.min(Math.max(0.125, scale), 500);
  $("#preview_slider").val(scale).change();

}

let scale = 1;
const el = document.querySelector("#preview");
el.onwheel = zoom;

el.addEventListener("wheel", zoom);

JS事件

在修改圖片時觸發事件,如此便能最後續處理,例如圖片縮圖僅需在圖片有更動時才須更新。

//修改圖片事件:
$(document).on("mainImageChanged",function(e,eventInfo){
  console.log(eventInfo);
  //.....
})

//修改圖片
function SetImg(newimg) {
  preview.src = "data:image/png;base64," + newimg;

	//觸發事件
  $(document).trigger("mainImageChanged",preview)
}

顏色通道

圖片RGB拆成可以分別開關的通道,在P圖的時候常會從通道中選一張二值化效果最好的通道做遮罩,不過目前本軟體的通道只能顯示、不能編輯(;´༎ຶД༎ຶ`)  (時間不夠..)。

@eel.expose
def useChannel(url, isR, isG, isB):
    isR = bool(isR)
    isG = bool(isG)
    isB = bool(isB)
    img = data_uri_to_cv2_img(url)

    g = np.zeros(img.shape)

    if isR:
        g[:, :, 2] = img[:, :, 2]
    if isB:
        g[:, :, 0] = img[:, :, 0]
    if isG:
        g[:, :, 1] = img[:, :, 1]

    blob = img_to_base64(g)
    eel.SetImg(blob)()

在地化

加上為了降低開發環境複雜度,本軟體前端使用較原生的JQuery輔助,沒有用如React.js等完整的web生態系統,能使用的工具較為受限,最後使用行之有名的i18n在地化解決方案應付小需求翻譯。

$.i18n({
  //不要打預設語系就會自動抓瀏覽器語言
  //locale: "zh-TW",
  //locale: "en",
});

$.i18n()
  .load({
    en: "/i18n/en.json",
    'zh-TW': "/i18n/zh-TW.json",
  })
  .done(function () {
    $('body').i18n();
  });


function SetLanguage(lang){
  $.i18n().locale = lang;
  $('body').i18n();
}
在地化效果。

簡易圖層

將兩張圖疊在一起
<!-- 混和圖片 -->
        <div class="opt_btns_container">
            <div class="opt_bar">加入圖片</div>
            <!-- 欲加入的圖片 -->
            <div class="side_small_preview_container" id="overlay">
                <img class="channel_preview_img" id="newLayerImage"
                    src="https://react.semantic-ui.com/images/wireframe/image.png">
                <script>
                    createSelect("overlay", "overlayMethod", "混合模式", [
                        { val: "normal", text: "正常" },
                        { val: "brigher", text: "變亮" },
                        { val: "darken", text: "變暗" },
                    ]) 
                </script>
                <!-- 加入按鈕 -->
                <label for="layerImgInput">
                    <img class="toggleImg" id="ct_b" isToggled="true" onclick="LoadLayerImage(this)"
                        src="./img/eye.png"></img>
                </label>
                <input type="file" id="layerImgInput" name="layerImgInput" hidden>
                <button class="opt_apply_btn" onclick="OverlayerImage()">套用</button>
            </div>
        </div>

HTML操作按鈕

//疊圖層
function OverlayerImage(){
  b64_str = realImage;
  layer_b64_str =newLayerImage.src ;
  opt=overlayMethod.value;
  SaveStep(b64_str);
  eel.overlayImage(b64_str,layer_b64_str,opt)

}
def overlayImage(url,layerUrl,opt):
    img = data_uri_to_cv2_img(url)
    img_overlay = data_uri_to_cv2_img(layerUrl)
    
    h, w = img_overlay.shape[:2]    
    hh, ww = img.shape[:2]    

    yoff = np.clip( round((hh-h)/2),0,hh)
    xoff =np.clip( round((ww-w)/2),0,ww)
    
    yMax= np.clip(yoff+h , 0 , hh)
    xMax= np.clip(xoff+w , 0 , ww)
    print(yoff,xoff , "|",yMax,xMax)

    xMin= min(ww,w)
    yMin= min(hh,h)

    result = img.copy()
    result[yoff:yMax, xoff:xMax] = img_overlay[:yMin,:xMin] #避免取出邊界
    
    blob = img_to_base64(result)
    eel.SetImg(blob, false, false)()

小優化 — 副檔名溝通

原本以png回傳,但圖片大小因此暴增10倍

# 圖片轉base64
def img_to_base64(img):
    b64_str = cv2.imencode(".png", img)[1].tobytes()
    blob = base64.b64encode(b64_str)
    blob = blob.decode('utf-8')
    print("process Time --- %s seconds ---" % (time.time() - start_time))
    return blob
jpg編碼檔案大小: 120KB
png檔案變成 1MB

解決辦法:互相溝通檔案型態

if (file) {
    fr.readAsDataURL(file);
    console.log(file);
    currentFileExtension =file.type.split("/")[1];
    //更新 footer KB數
    $("#fileSize_text").html(Math.round(file.size / 1024) + "KB");
    //設定副檔名
    eel.setFileExtension(currentFileExtension)
  } else {
    console.log("沒有檔案");
    alert("不支援檔案類型");
  }
@eel.expose
def setFileExtension(ext):
    global current_file_extension
    current_file_extension="."+ext

【補充】

Base64 轉法造成卡頓?

原本以為卡頓問題是反覆的base64轉碼造成的。

import time
image =""

# base64轉np image
def data_uri_to_cv2_img(uri, forceUpdate=false):
    global image
    start_time = time.time()
    #print(image)
    if image!="" and not forceUpdate:
        return image

    uri_spt = uri.split(',')
    if(len(uri_spt)>1):
        encoded_data=uri_spt[1]
    else:
        encoded_data=uri_spt[0]
  
    img = base64.b64decode(encoded_data)
    npimg = np.frombuffer(img, np.uint8)
    image = cv2.imdecode(npimg, cv2.IMREAD_COLOR)
    print("base 64 decode Time --- %s seconds ---" % (time.time() - start_time))
    return image

做了點log發現轉碼所需時間不長,主要問題是Chart.js插件效能問題。(javascript – ChartJS is slow – Stack Overflow)

base 64 decode Time --- 0.010005474090576172 seconds ---
base 64 decode Time --- 0.007999897003173828 seconds ---
base 64 decode Time --- 0.008002042770385742 seconds ---
base 64 decode Time --- 0.007974386215209961 seconds ---
base 64 decode Time --- 0.007002353668212891 seconds ---
base 64 decode Time --- 0.0069997310638427734 seconds ---
base 64 decode Time --- 0.00800013542175293 seconds ---
base 64 decode Time --- 0.007972240447998047 seconds ---
base 64 decode Time --- 0.007972240447998047 seconds ---

ERROR : WebSocket connection to ‘ws://localhost:8000/eel?page=main.html’ failed: Could not decode a text frame as UTF-8

https://github.com/python-eel/Eel/issues/299

在js呼叫另一個eel方法時,上一個還沒結束產生的ERROR。

打包輸出exe檔案

Eel is a little Python library for making simple Electron-like offline HTML/JS GUI apps, with… (curatedpython.com)

*EelPoriect拼錯字

cd src
python -m eel app.py web

---
python -m eel ./src/app.py ./src/web

這篇文章 【徵文賞-動態網頁】優選|使用前端技術製作自己的修圖軟體吧(Python + OpenCV + eel) – 林慶佳 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
【互動網頁程式教學】活用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 - 互動程式創作台灣站

]]>
【互動網頁程式教學】用 GSAP 製作直播互動動態效果 https://creativecoding.in/2022/03/10/gsap-livestream-webpage/ Thu, 10 Mar 2022 05:20:00 +0000 https://creativecoding.in/?p=1684 本篇教學帶大家使用 vue.js, vue-cli, gsap 製作模擬 facebook 手機版的直播畫面,連結視訊鏡頭中使用者的畫面,並加上留言區塊以及可以點擊的表情符號等功能。跟著老闆一起,動態網頁製作好簡單。

這篇文章 【互動網頁程式教學】用 GSAP 製作直播互動動態效果 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
用 GSAP 製作直播互動動態效果成品
用 GSAP 製作直播互動動態效果成品

本文翻自【互動網頁程式教學】用 GSAP 製作直播互動動態效果,若是對文章內容有疑問,或是想要老闆手把手帶你飛,都可以觀看影片跟著動手做,也附上這次成品

這次要帶大家使用 vue.js, vue-cli, gsap 來模擬 facebook 手機版的直播畫面,首先將視訊鏡頭中使用者的畫面做為直播畫面,下半部留言區塊點擊表情符號後,表情符號加上彈幕的動畫效果。輸入訊息後,訊息會經由轉場動畫出現在畫面中,使用者也能點選刪除留言來看到訊息的離場動畫。

製作表情符號進出場的動畫會使用到 gsap ,gsap 是由 greenSock所開發,常被用來取代以前的 flash,提供許多製作動畫的套件,包含這次會使用到的 tweenMax 及 timelineMax,由於是透過 js 所撰寫,動畫呈現有更大的自由度。但要注意個人和商用部分功能是免費的,若需引入專案時要多留意。範例中,也會帶大家使用 vue transition 提供的兩種模式,來觸發表情符號與留言的進出場效果。

這次直播筆記會帶大家學會以下內容:

  • 認識 vue 中的 ref, $refs
  • 使用原生 js 載入視訊影像
  • 使用 gsap 製作表情符號過場動畫
  • 使用 vue 中的 transition 製作過場動畫

事前準備

開發環境

老闆在這次專案改使用 CodeSandbox進行開發,關於環境和其他套件的設定,同學可以參考老闆的成品

CodeSandbox 比起之前老闆示範時常用的Codepen來說,功能較完整一些,除了提供大家建立 project、安裝需要使用的 library 之外,也能在上面跑 npm 的 package 設定。製作大型專案需要測試時,老闆習慣會使用 CodeSandbox ,但小缺點就是不支援 emmet(註:輸入簡化碼後會自動產生完整HTML & CSS程式碼,加快程式碼輸入,也降低手誤機率),撰寫程式碼較不方便一些。

透過 new sandbox 創建新的專案,選擇 Vue( vue2 的 cli)後,可以看到左邊有 files 欄,包含專案所有資料夾及檔案,這次專案只會在 App.vue 這支檔案中開發,同學們不用被資料夾結構嚇到。

打開 App.vue 檔案之後可以發現有三個區塊分別為:

  • <template>:撰寫 html,改使用 pug
  • <script>:撰寫 vue 及 js
  • <style>:撰寫 css,改使用 scss

首先,將使用不到的元件 HelloWorld 相關的敘述全部拔除,準備好基本的結構後,可以看到右邊的畫面只剩下一個 vue 的 logo。

//將HTML撰寫語言改成pug
<template lang="pug">
#app
  img(alt="Vue logo", src="./assets/logo.png", width="25%")
</template>

<script>
export default {
  name: "App"
};
</script>

//將撰寫語言改成scss
<style lang="scss">
#app {
  font-family: "Avenir", Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

接著,讓我們來安裝這次會使用的套件。畫面的左欄有 Dependencies,因為我們在創建專案時選擇 vue,可以看到 codeSandbox 已經幫我們裝了兩個套件。我們只需要再將 gsap 載入即可,在放大鏡區塊(Add Dependency)打入 gsap 並選擇安裝,就大功告成。

在Code Sandbox裡加入gsap套件

接下來會使用到的 API:

這次專案會使用以下的內容,這邊先重點整理給大家,不清楚的地方,可以透過後面跟著老闆操作,或是觀看相關文件,了解每個 api 使用時機。

Vue

  • script
    • el:資料要綁定的區塊
    • data:vue 要綁定的資料放置處
    • mounted:vue 的生命週期, el 被掛載之後會執行裡面的程式碼
    • methods:使用到的 function 放置處
    • computed:計算屬性,會因為 data 內的值改變,而跟著變動
    • $refs:可以搭配 ref 屬性來取得 DOM 元件 (延伸參考資料)
// javascript
var vm = new Vue({
  el: '#app',
  data: {
    text: 'Hello World',
    texts: ['H', 'i']
  },
  mounted () {...},
  methods: {
    changeText () { 
      this.$refs.input.focus()
    }
  },
  computed: {
    showText () { return ...}
  }
})
  • template:畫面部分會使用到以下內容
    • {{text}}:將資料綁定到畫面中顯示
    • v-model:將資料綁定到畫面中顯示或修改
    • v-for:讓陣列資料重複產生 dom,可以搭配索引值綁定
    • :key:可以搭配 v-for 使用,提供 vue 識別每個 dom 是不同的,在傳入 v-for 的陣列中,key 要是獨特的值,避免識別上出錯。
    • @click=””:當點擊目標物會觸發傳入 click 的內容
    • ref:可以在程式碼中搭配 $refs 取得 DOM 元件(延伸參考資料)
// html
#app
	p {{text}}
	input (v-model="text ref="input")
	p(v-model="showText")
	div(:class="")
	div(v-for="(item, idx) in texts", :key="idx") {{item}}
	button(@click="changeText()")
  • vue – transition-group:vue 提供給在 dom 要被加入、移除或更新時的動態效果,使用方法會在後面實做中解說。若想要參閱官方說明文件可點此閱讀
  • js:Math.random():會產出一個大於等於 0、小於 1 之間的隨機小數。若想要參閱更詳細的說明文件可點此閱讀

gsap

  • tweenMax:針對指定的 DOM 在動畫時間內執行動畫
TweenMax.to(執行動畫的DOM, 動畫時間, {
  y: 200, // 位移 200 px
  rotate: 360, // 旋轉 360 度
  delay: 3, // 3 秒後才執行動畫
  repeat: 2, // 會重複執行兩次
  yoyo: true // 會倒帶後再執行一次
});
  • timeLineMax:可讓動畫多段依序進行 ,使用 to 去接後續要播放的動畫
let tl = new TimelineMax() // 新增 tl 變數
tl.to(this.$refs.logo, 1, { // 使用 this.$refs 去取得 dom
  y: 200,
  rotate: 360
}).to(this.$refs.logo, 1, {
  scale: 2
})

js – getUserMedia

提供瀏覽器獲得使用者影像,navigator 會詢問瀏覽器有沒有影片可以使用,找到之後將其放到 video tag 中,要記得提供瀏覽器取用麥克風或錄影機的權限。(延伸參考資料)

var constraints = { audio: true, video: { width: 1280, height: 720 } };
navigator.mediaDevices
  .getUserMedia(constraints)
  .then((mediaStream) => {
    var video = this.$refs.myVideo;
    video.srcObject = mediaStream; // 將 video 指定到指定的 DOM 中
    video.onloadedmetadata = function (e) {
      video.play();
    };
  })
  .catch(function (err) { // 出錯時的處裡
    console.log(err.name + ": " + err.message); 
  });

跟著老闆開始動手做

操作一段與多段動畫

我們在環境準備階段已經把 gsap 裝到專案中,首先我們使用 vue 的 logo 來練習 gsap 製作動畫方式。gsap 內有很多個製作動畫的方式,老闆帶大家操作兩種型式的動畫,分別為 tweenMax, timelineMax 兩種。

讓 logo 動起來之前,先介紹兩種方式讓 gsap 抓到 logo 這個 dom 元件。

  • html 賦予 id,使用 #logo 讓 gsap 取得 dom
  • html 賦予 ref,使用 $refs 讓 gsap 取得 dom

我們想要讓 logo 一秒內下滑,並旋轉,這邊會使用到 gsap 的 TweenMax,所以我們將它 import 到專案中,並在 vue 的 mounted 階段操作動畫,mounted 是 vue 的生命週期,會在 vue app 載入後執行裡面的動畫,寫法及參數如下。gsap 有許多的動畫值可以操作,建議同學們不用死背,需要時去查文件即可。

<template lang="pug">
#app
  img#logo(alt="Vue logo", 
           src="./assets/logo.png", 
           width="25%")
</template>

<script>
import { TweenMax } from "gsap";
export default {
  name: "App",
  mounted() {
    TweenMax.to("#logo", 1, {
      y: 200, // 位移 200 px
      rotate: 360, // 旋轉 360 度
      delay: 3, // 3 秒後才執行動畫
      repeat: 2, // 會重複執行兩次
      yoyo: true // 會倒帶後再執行一次
    });
  },
};
</script>
使用TweenMax.to做出旋轉下滑再倒帶的動畫
使用TweenMax.to做出旋轉下滑再倒帶的動畫

完成一段式的動畫後,會發現 TweenMax.to() 無法滿足多段式的動畫需求。如果我們希望動畫是多段小動畫依序進行,那要一直寫許多 TweenMax.to 並加上 delay 嗎?

其實,gsap 有另一個功能 TimelineMax 就可以達成我們的需求,使用 TimeLineMax 時,要注意需要先新增 new TimelineMax 的變數,使用的方式是第一段動畫完成後,使用 to 去接後續要播放的動畫,傳入的參數與 TweenMax 一樣。使用方法如下:

我們前面提到有兩種方式可以取得 dom 元件,這邊改使用 vue 所提供的 ref 及 $refs 去取得要執行動畫的 dom 元件。

<template lang="pug">
#app
  img(ref="logo", alt="Vue logo", src="./assets/logo.png", width="25%") 
  //- img 多加 ref 屬性
</template>

<script>
import { TweenMax, TimelineMax } from "gsap";
export default {
  name: "App",
  mounted() {
    let tl = new TimelineMax() // 新增 tl 變數
    tl.to(this.$refs.logo, 1, { // 使用 this.$refs 去取得 dom
      y: 200,
      rotate: 360
    }).to(this.$refs.logo, 1, {
      scale: 2
    })
  },
};
</script>
使用TimelineMax做出旋轉下滑再放大的兩段動畫
使用TimelineMax做出旋轉下滑再放大的兩段動畫

直播畫面與 live 標籤

接著來處理畫面,會處理的內容分別為:模擬手機直播畫面的樣式切版、使用 video 視訊畫面做為直播影片、利用 ref 來取得 video 的位置、 live 動畫效果與時間計數器。

  • 模擬手機畫面:只需要針對畫面樣式進行調整,在幫 live 區塊做定位時,記得在 #app 多加上 position: relative,否則預設會以 body 做為參考。
  • 使用 video 作為直播影片:在畫面上準備待會要放置 video 的 dom,並在裡面放上一個 video tag ,這邊可以對 video tag 使用 muted 屬性,待會的影像就會是靜音的狀態。接著在 mounted 中使用 getUserMedia 來獲得影像。vue 初始化時,會先建立一個空的 video DOM,到了 mounted (vue app 載入之後)階段,navigator會詢問瀏覽器有沒有影片可以使用,找到之後將其放到 video tag 中,要記得提供瀏覽器取用麥克風或錄影機的權限。
  • 這邊我們也練習前面提到的 ref ,來取得 video tag,MDN 上面提供的範例是使用 function,因為 function 有自己的 scope,無法在函式內部使用 this 取得 vue 本身。有兩種解法,在外面宣告 _this 變數,或是用 es6 的 arrow function。
  • live 動畫效果:要幫 live 字樣加上呼吸燈的亮暗亮暗效果,除了前面有練習過的 repeat, yoyo 屬性外,也會使用到 gsap 中的 easing api,可以選擇自己喜歡的時間曲線後,在專案中引入。
  • 時間計數器:在 mounted 中使用 setInterval 來進行每秒都會增加時間的值,利用這個值換算成需要的格式。透過 computed 來回傳需要的字串,computed 的使用時機為「已經知道資料是什麼,基於原本的值去加工後回傳,不會影響到原本的資料」。也利用 padStart 將時間中的時分秒三個資料都能是2位數,在最後回傳結果字串時,利用 es6 的頓號`來組裝字串。
<template lang="pug">
#app
  .liveLabel
    .red(ref="liveTag") LIVE //LIVE小標
    .counter {{timeLabel}} //時間計數器
  .videoContainer
    video(ref="myVideo", autoplay="true", muted)
</template>

<script>
import { TweenMax, Power0 } from "gsap";
export default {
  name: "App",
  data() {
    return {
      time: 0,
    };
  },
  computed: {
    timeLabel() {
      let sec = this.time % 60;
      let min = Math.floor(this.time / 60) % 60;
      let hour = Math.floor(this.time / 3600) % 24;
      let pd = (num) => (num + "").padStart(2, "0"); // padStart api, 不足長度字串在前面補上0
      return `${pd(hour)}:${pd(min)}:${pd(sec)}`;
    },
  },
  mounted() {
  // 每秒執行一次增加 time 的值
    setInterval(() => {
      this.time++;
    }, 1000);
  // Live 呼吸燈
    TweenMax.to(this.$refs.liveTag, 1, {
      css: {
        backgroundColor: "rgba(255, 0, 0, 0.3)",
      },
      ease: Power0.easeNone,
      repeat: -1,
      yoyo: true,
    });

  // 影片串流
    var constraints = { audio: true, video: { width: 1280, height: 720 } };
    navigator.mediaDevices
      .getUserMedia(constraints)
      .then((mediaStream) => { // 改成 arrow function, this就不會抓到內部而是外層的元件
        var video = this.$refs.myVideo;
        video.srcObject = mediaStream;
        video.onloadedmetadata = function (e) {
          video.play();
        };
      })
      .catch(function (err) {
        console.log(err.name + ": " + err.message);
      });
  },
};
</script>

<style lang="scss">
html,
body {
  background-color: #333;
  display: flex;
  justify-content: center;
  align-items: center;
}
#app {
  position: relative;
  font-family: "Avenir", Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
  width: 390px;
  height: 744px;
  background-color: white;
}
.videoContainer {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 450px;
  overflow: hidden;
  video {
    height: 100%;
  }
}
.liveLabel {
  position: absolute;
  color: #fff;
  display: flex;
  left: 50%;
  top: 30px;
  transform: translateX(-50%);
  .red {
    padding: 5px 10px;
    background-color: red;
    font-weight: 900;
  }
  .counter {
    padding: 5px 10px;
    background-color: rgba(black, 0.6);
  }
}
</style>
順利將直播影片置入網頁中
順利將直播影片置入網頁中

表情符號功能

接下來我們要來做表情符號清單與點擊表情符號後的效果。

  • 表情符號清單:新增一個變數記錄所有表情符號,結合 split 就能將表情字串轉換成陣列,調整樣式後,將選單 #emojiToolBar 放置在畫面的右下方。
  • 表情符號被點擊後動畫效果:清單中的表情符號被點擊後,使用 css 的類別選擇器 :active 來改變 transition 做為被點擊的動態回饋。我們也在清單中的每顆表情符號使用 vue 的語法 @click,當表情符號有點擊事件時,會觸發 addEmoji 函式,同時將被點擊的表情做為參數傳入函式中。
  • 記錄有哪些表情符號被點擊:當使用者點擊表情符號後,我們需要記錄有什麼表情符號被觸發,才有辦法去跑對應的動畫,所以在 data 中我們新增一個變數 currentEmojiList ,當清單中的符號被按壓後,會將新的表情符號 push 到陣列裡。
  • nextTick 確保資料已更新(延伸參考資料):因為 vue 不是即時更新,資料更新和畫面更新有時間差,所以在更新資料後,馬上去抓新的 dom 會失敗,改使用 nextTick 確定資料更新完畢才跑後續的程式碼。
  • tweenMax 初始化設定:使用了 gsap.set() 這個 api,可以針對準備進場的動畫做初始設定,老闆希望表情剛進場時能從小變到大,所以我們在 set 中新增一個 scale: 0.2。
  • 表情符號進出場動畫:期望的動畫流程為,按壓表情符號後,先往上飄並慢慢放大到定點,往左飄變小並離場,因為每個表情符號都要兩段式的動畫,這時就可以使用前面提到的 TimelineMax 來達成效果。若是有超出畫面則被隱藏,只要透過 css 去對 #app 做 overflow: hidden 即可。
  • 加上隨機數值:完成前面幾點,目前的動畫會有點死板,為了讓動畫更自然,我們讓每個表情起始點不同,上移的距離也不同,製造出交錯的表情符號動畫。分別在 set 內新增一個 x 的值,隨機從0~-100 中挑一個數並加上20;也讓每個表情符號上移的 y 位置不同 ,所以在第一段動畫的終點,讓 y 的值組合不同的 random數。
  • 時間函數:大致功能都完成後,希望兩段動畫能再自然一點,所以為兩段動畫都加上速度曲線的值 ease,大家也可以參考相關文件,動手試試不同種的速度效果。
<template lang="pug">
#app
  ...
  .contentArea
    ul.floatingEmojiList
      li.floatingEmoji(
        v-for="(emoji, emojiId) in currentEmojiList",
        :class="`emoji_${emojiId}`"
      ) {{ emoji }}
  ul#emojiToolBar
    li.emojiBtn(v-for="emoji in emojis", @click="addEmoji(emoji)") {{ emoji }}
</template>

<script>
import { TweenMax, TimelineMax, Power0, Power1, Power4 } from "gsap";

const emojiList = "👍,🎉,😂,😯,😢,😡";

export default {
  name: "App",
  data() {
    return {
      time: 0,
      emojis: emojiList.split(","),
      currentEmojiList: [],
    };
  },
  computed: {
    ...
  },
  mounted() {
    ...
  },
  methods: {
    addEmoji(emoji) {
      this.currentEmojiList.push(emoji);
      let tl = new TimelineMax();
      this.$nextTick(() => {
        let _id = `.emoji_${this.currentEmojiList.length - 1}`;
        tl.set(_id, {
          scale: 0.2,
          x: Math.random() * -100 + 20,
        })
          .to(_id, 1, {
            y: -200 + Math.random() * -100,
            scale: 1,
            ease: Power4.easeOut,
          })
          .to(_id, 3, {
            x: -500,
            scale: 0.6,
            ease: Power1.easeIn,
          });
      });
    },
  },
};
</script>

<style>
...
#app {
  ...
  overflow: hidden;
}
...
#emojiToolBar {
  position: absolute;
  right: 0;
  bottom: 0;
  margin: 0;
  display: flex;
  list-style: none;
  .emojiBtn {
    font-size: 40px;
    width: 50px;
    cursor: pointer;
    transition: 0.5s;
    &:active {
      transition: 0s;
      transform: scale(0.8);
    }
  }
}
.contentArea {
  position: relative;
}
.floatingEmojiList {
  list-style: none;
  .floatingEmoji {
    position: absolute;
    right: 50px;
    top: 50px;
    font-size: 50px;
  }
}
</style>

改使用 transition 元件製作表情符號動畫

接下來我們來使用 vue 中 transition-group 元件改寫表情符號進場的過程。vue 提供了 transition 與 transition-group 兩種元件,讓元件在特定的時間點觸發指定的 function 或加上特定的 class 名稱(詳細請參考延伸資料)。transition 與 transition-group 的差別在於,如果只有一個元件會改變使用前者,這個專案是用在由 for 產出的 li 元件們上,所以使用後者。接著就可以把原本在 addEmoji 裡的程式碼搬到 enter 中。

此時,也可以拔掉 nextTick ,因為在 transition-group 上的屬性 v-on:enter 會在確定資料更新才觸發進場,就不用再使用 nextTick 去監聽元件是否生成。要注意的是,如果有使用 v-for ,記得要補上 key 值。

<template lang="pug">
#app
  ...
  .contentArea
    ul.floatingEmojiList
      transition-group(v-on:enter="enter") //子元件進場時會觸發 enter 函式
        li.floatingEmoji(
          v-for="(emoji, emojiId) in currentEmojiList",
          :key="emojiId", // 補上 key
          :class="`emoji_${emojiId}`"
        ) {{ emoji }}
  ul#emojiToolBar
    li.emojiBtn(v-for="emoji in emojis", @click="addEmoji(emoji)") {{ emoji }}
</template>

<script>
import { TweenMax, TimelineMax, Power0, Power1, Power4 } from "gsap";

const emojiList = "👍,🎉,😂,😯,😢,😡";

export default {
  ...
  mounted() {
   ...
  },
  methods: {
    enter(el) { // 動畫進場時觸發的動畫
      let tl = new TimelineMax();
      tl.set(el, {
        scale: 0.2,
        x: Math.random() * -100 + 20,
      })
        .to(el, 1, {
          y: -200 + Math.random() * -100,
          scale: 1,
          ease: Power4.easeOut,
        })
        .to(el, 3, {
          x: -500,
          scale: 0.8,
          ease: Power1.easeIn,
        });
    },
    addEmoji(emoji) { // 將動畫內容搬到 enter 函式中
      this.currentEmojiList.push(emoji);
    },
  },
};
</script>

留言區塊

製作送出留言的功能,分別有以下項目需要完成:

  • 準備假資料:先準備單筆資料的格式,分別有頭像顏色、發言人、內容。
  • 輸入框及送出按鈕:這次只是模擬訊息送出的狀態,機制會是使用者輸入留言,成功送出訊息時,將這個訊息加到 comments 中,並將輸入框清空。若是輸入框為空的,則使用預設的內容送出。
  • 預設訊息轉成 json 字串格式再轉回來:要多做這層處理,是因為預設留言 message 是物件,直接賦值的話會是傳參考,需要透過這種方式,創造一個全新的物件。
  • 調整表情符號 bar 樣式:將表情工具的寬度改為 100%,加上透明背景。
<template lang="pug">
#app
  ...
  .contentArea
    input(v-model="message")
    button(@click="addMessage") Add Comment
    .comments(v-for="(comment, commentId) in comments", :key="commentId")
      .head(:style="{ backgroundColor: comment.color }")
      .content
        .name {{ comment.name }}
        .sentence {{ comment.content }}
</template>

<script>
import { TweenMax, TimelineMax, Power0, Power1, Power4 } from "gsap";

const emojiList = "👍,🎉,😂,😯,😢,😡";

let message = {
  color: "#333",
  name: "Lorem ipsum",
  content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
};

export default {
  name: "App",
  data() {
    return {
      ...
      comments: [],
      message: "",
    };
  },
  computed: {...},
  mounted() {...},
  methods: {
    addMessage() {
      const newMessage = JSON.parse(JSON.stringify(message)); // 創造一個全新的物件
      if (this.message !== "") {
        newMessage.content = this.message;
        this.message = "";
      }
      this.comments.push(newMessage);
    }
    ...
  },
};
</script>

<style lang="scss">
#emojiToolBar {
  position: absolute;
  right: 0;
  bottom: 0;
  margin: 0;
  padding: 5px;
  display: flex;
  justify-content: flex-end;
  list-style: none;
  width: 100%;
  background-color: rgba(#fff, 0.8);
	...
}
...
.contentArea {
	position: relative;
  list-style: none;
  padding-left: 0px;
  margin-left: 10px;

  .comments {
    display: flex;
    list-style: none;
    padding: 5px;
    font-size: 15px;

    .head {
      width: 40px;
      height: 40px;
      border-radius: 50%;
      margin-top: 10px;
      margin-right: 20px;
      margin-left: 10px;
      flex-shrink: 0;
    }
    .content {
      text-align: left;
      .name {
        font-weight: 900;
      }
    }
  }
}
</style>
加上留言功能以及表情符號清單,越來越像直播的頁面了
加上留言功能以及表情符號清單,越來越像直播的頁面了

新增/刪除訊息

最後我們利用新增訊息功能,來練習 transition,首先因為每筆訊息都是用 v-for 跑出來,所以我們要用 transition-group。

  • 使用 name 來幫訊息加上動畫:前面的表情符號我們是用 v-on:enter ,當元件被監聽到加入畫面中時,觸發 enter 函式。這邊改使用 name 來觸發(延伸閱讀了解Transition),動態加上 class , vue 總共提供六個時間點,讓使用者為他們加上進場或離場動畫,同學可以去觀察 vue 在 dom 上做了什麼事。
  • 調整對應時間點的動畫樣式:大家可以觀察當我們使用 name 來製作動畫後,vue 會在特定時間幫我們在對應的元件上新增 class。利用這些 class 我們就可以來製作過場動畫。要注意動畫的權重如果太小,有些效果無法順利觸發。
  • 刪除訊息:既然完成了新增訊息,刪除訊息也能快速完成,老闆希望保留訊息的完整性,所以這邊調整成,當使用者點擊移除訊息的按鈕,只會在這則訊息的物件上新增一個 delete: true 的值,搭配 v-if 就能將這則訊息隱藏。
<template lang="pug">
#app
  ...
  .contentArea
    input(v-model="message")
    button(@click="addMessage") Add Comment
    transition-group(name="fade") // 改使用 name 製作動畫
      .comments(
        v-for="(comment, commentId) in comments",
        :key="commentId",
        v-if="comment.delete != true" // 當delete 的值不為 true 時,隱藏訊息
      )
        .head(:style="{ backgroundColor: comment.color }")
        .content
          .name {{ comment.name }}
          .sentence {{ comment.content }}
        button(@click="removeComment(comment)") - // 點擊後觸發 removeComment 函式
    ul.floatingEmojiList
      transition-group(v-on:enter="enter") // 當元件進入時,觸發 enter 函式
        li.floatingEmoji(
          v-for="(emoji, emojiId) in currentEmojiList",
          :key="emojiId",
          :class="`emoji_${emojiId}`"
        ) {{ emoji }}

  ul#emojiToolBar
    li.emojiBtn(v-for="emoji in emojis", @click="addEmoji(emoji)") {{ emoji }}
</template>

<script>
import { TweenMax, TimelineMax, Power0, Power1, Power4 } from "gsap";

const emojiList = "👍,🎉,😂,😯,😢,😡";

let message = {
  color: "#333",
  name: "Lorem ipsum",
  content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
};

export default {
  name: "App",
  data() {
    return {
      time: 0,
      emojis: emojiList.split(","),
      currentEmojiList: [],
      comments: [],
      message: "",
    };
  },
  computed: {
    timeLabel() {
      let sec = this.time % 60;
      let min = Math.floor(this.time / 60) % 60;
      let hour = Math.floor(this.time / 3600) % 60;
      let pd = (num) => (num + "").padStart(2, "0");
      return `${pd(hour)}:${pd(min)}:${pd(sec)}`;
    },
  },
  mounted() {...},
  methods: {
    removeComment(comment) {
      comment.delete = true;
    },
    addMessage() {
      console.log("hi");
      const newMessage = JSON.parse(JSON.stringify(message));
      if (this.message !== "") {
        newMessage.content = this.message;
        this.message = "";
      }
      this.comments.push(newMessage);
    },
    ...
  },
};
</script>

<style lang="scss">
...
.contentArea {

  .comments {
    ...
    &.fade-enter-active, // 利用 transition name 做進出場動畫
    &.fade-leave-active {
      transition: all 0.5s;
    }
    &.fade-enter,
    &.fade-leave-to {
      opacity: 0;
      transform: translateY(10px);
    }
    ...
  }
}
</style>
增加刪除留言的功能
增加刪除留言的功能

老闆來結語

這邊再提供一次範例的成果,讓大家在實作時參考,也帶大家快速回顧一次製作流程:

  1. 使用 codeSandbox 來開發專案,安裝 vue-cli, gsap 後,整理預設提供的檔案。
  2. 結合 vue 的 ref, $refs 來取得元件。
  3. 透過 gsap 中的 tweemMax, timelineMax 來製作一段或多段式的動畫。
  4. 利用原生 js 的影片串流模擬直播畫面,並加上 live 與時間計數器的效果。
  5. 了解 vue 提供的 nextTick 能夠確保資料更新後才進行畫面渲染。
  6. 製作表情符號工具欄,在使用者點擊後,能使用 timelineMax 製作表情符號動畫,結合 random 的 api 讓表情動畫更加自然。
  7. 使用 vue transition-group 來做為表情符號與新增刪除留言的進出場動畫。

這次利用 fb 的直播畫面做為目標,帶大家練習 gsap 製作動畫的方式,大家也可以挑戰自己,看看線上有哪些產品或網站有使用到動畫,想辦法使用 gsap 來實現,做為刻意練習的目標。萬事起頭難,一個作品不可能一步到位,大家在開發時,可以先將最終目標拆分成不同階段任務,從一開始的雛型慢慢開發出每個區塊,最後組裝在一起,就會十分有成就感啦!

跟著老闆上課去 👉 動態互動網頁程式入門(HTML/CSS/JS) 👉 動畫互動網頁特效入門(JS/CANVAS)

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

墨雨設計banner

這篇文章 【互動網頁程式教學】用 GSAP 製作直播互動動態效果 最早出現於 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 - 互動程式創作台灣站

]]>
Vue.js入門:英文2000字互動遊戲網頁 https://creativecoding.in/2021/08/26/vue-js-%e8%8b%b1%e6%96%872000%e5%ad%97%e4%ba%92%e5%8b%95%e9%81%8a%e6%88%b2%e7%b6%b2%e9%a0%81/ Thu, 26 Aug 2021 01:38:00 +0000 https://creativecoding.in/?p=1397 你多久沒鍛鍊英文了呢?這次直播使用HTML(Pug)CSS(Sass)以及Javascript(Vue.js)來完成一個練習2000字英文單字的小互動遊戲網頁,難度適中,尤其適合剛開始學習Vue.js的新手,無論是跟著步驟逐步操作,或是聽老闆的影片都能快速完成。

這篇文章 Vue.js入門:英文2000字互動遊戲網頁 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
英文2000字選擇題互動網頁完成圖
英文2000字選擇題互動網頁完成圖

這次直播的主題來自於老闆在2018年印象清華-物聯網科技藝術展中創作的展品〈英文8-2〉,其概念為十根代表清大不同學院的光柱,使用者只要掃描光柱上的QR Code就會跳出互動式的英文單字題目,只要答對題數越多、分數就越多,累積的分數便會即時地投射到光柱上,形成高高低低、動態交錯的有趣光景。

在這次直播中會來聊些這個專案內使用Vue的相關經驗,聊聊製作互動裝置藝術實作時整合的各種辛酸血淚史,以及如何快速地解析別人資料,利用Vue框架製作成幫你找回國中逝去英文能力的遊戲。

我們將以Code Pen做為本次實作的平台,這是一個可以在創作的當下即時看到程式碼運作狀況的線上程式碼編輯器,只要簡單註冊就可以使用囉!

如果想搭配直播影片一起實作,請往這邊走 👉🏻 https://www.youtube.com/watch?v=maFbo96YT8U

前期準備

在開始之前我們根據概念來進行規劃,想像一個英文單字的互動答題App需要哪些東西:

  1. 整理網路上現成的單字表,把資料變成符合我們條件的JSON格式物件,單字表必須同時具備英文、中文與詞性(今天借用的是109英文銜接教材2000單字
  2. 產生隨機的英文題目,並利用整理後的物件選出正確答案和其他類似的詞當作選項,並判斷答題者的正確與否
  3. 如果答題者正確,跟後端同步狀態累加分數

首先在Code Pen上開一個新的pen,將HTML的預處理器設定成Pug、CSS的預處理器設定成Sass、JS的CDN掛入Vue。

程式環境設定
程式環境設定

接著把單字表上的文字複製下來貼到Javascript中,var一個a,並且用ES6的頓號 ` 把文字包起來。

//Javascript
var a = `
A
able adj. 有能力的
about prep. 有關
above adv. 在上方
`

註:考慮篇幅關係這邊只貼上部分A字首的單字,實際資料請參考單字表。

我們快速分析一下他的架構組成,參考字首A的部分得知單字表主要可以分為:字首的段落開頭、英文、詞性、中文,也就是──只要是沒有英文單字的那一行就不會有「.」,如果說我們今天要把單字整理成一個一個的物件時,可以把每一行先分割出來、把含有「.」的留下,再分別拆解成英文、詞性、中文,這就是我們所需的資料。

1. 拆解單字表

利用語法split以空行來做分隔,再用語法filter把含有.的行過濾保留下來,利用list.lengthlist[n]在console查看過濾後的listlist2數量上的差異,代表我們的資料越來越乾淨了。利用語法map把原先陣列的一行一行轉化成一個一個,再存成另一個陣列,轉換的條件為用空格分割,console會發現list3裡面裝著一坨拉庫的[object Array](3)。再把list3拆分成wordcatatrans,分別對應英文、詞性、中文的物件。

//Javascript
var list = a.split("\n") //分割空行
var list2 = list.filter(item=>item.indexOf(".")!=-1) //過濾沒有.
var list3 = list2.map(item=>item.split(" ")) //單行轉單個
var list4 = list3.map(item=>({
  english: item[0],
  cata: item[1],
  trans: item[2]
})) //拆分成英文、詞性、中文

資料搬運小幫手Vue

Vue的特色在於資料雙向綁定,相較於jQuery需要選取物件、重新定義、再塞回去以直接操作 DOM 物件為主的方式,利用Vue的同步更新渲染資料可以幫助我們節省不少時間。Vue的寫法為在JS透過 new Vue建立作用範圍和待即時同步的資料,同時在HTML以{{}}包裹被更新的變數。以下為官方網站所舉的範例,Vue會將大括號{{}}的內容對應到message狀態,並且將之即時渲染至畫面上,也就是所指的「宣告式渲染」。

//HTML
<div id="app">
  {{ message }}
</div>
//Javascript
var app = new Vue({
  el: '#app',
  data: {
    message: 'Hello Vue!'
  }
})

延伸閱讀: 「Vue.js 學習筆記 Day1」- 建立 Vue 應用程式 重新認識 Vue.js | Kuro Hsu

2. 實際運用Vue-單字小卡

運用Vue和剛剛整理好的單字表資料來試做一些英文小卡吧!取出list4中倒數20個單字用v-for迭代陣列中的物件,指定其資料種類並渲染在li的span,再給一些CSS的參數後就可以看到一張張排列整齊的你國中的惡夢單字小卡。透過Vue的幫忙,我們不用自己產生元件跟呈現,只需要確定資料是否正確即可。

//HTML
#app 
    h2 我的名字是{{name}}
    ul
      li(v-for="word in words")
        span {{word.english}} {{word.cata}} {{word.trans}} //指定word中的種類
//CSS
html,body
  background-color: #222

ul
  li
    background-color: #fff
    padding: 20px
    display: inline-block
    margin: 20px
    width: 200px
//Javascript
var vm = new Vue({
  el: '#app',
  data: {
    name: "Frank",
    catas: ["a","b","c","d"],
    words: list4.slice(-20) //負號代表從後面數來的20個單字
  }
})
英文2000字選擇題互動網頁:步驟二,製作出單字卡
英文2000字選擇題互動網頁:步驟二,製作出單字卡

3. 製作答題選項

製作完單字小卡有沒有覺得長得很像我們的答題選項呢?在Vue中我們定義methods為操作不同 DOM 元素的方法,這邊需要綁定幾個動作:

  1. click DOM元素根據滑鼠點擊的動作,在console回傳所點擊的單字。
  2. getOptions挑選同詞性、同字首的隨機四個單字。用filter過濾word.cata == question.cata也就是詞性需與答案相同,過濾第二次word.english[0] == question.english[0]英文單字的字首(第0個字)需相同,過濾第三次word.english !== question.english確認答案不會等於題目。
  3. 使用sort把陣列的順序打亂:.sort((a,b)⇒a-b)是將大的往後排,但sort((a,b)=>Math.random())則是隨機取值,再扣掉比較函數0.5後成為真正隨機排序的陣列,加上.slice(0,4)限縮在一次只取四個單字。
  4. .sort前面加入第二個.slice()把原本的元素複製一份成新的陣列,避免影響到既有陣列的順序,雖然針對新的陣列動屬性依然會影響原先的物件,但兩個陣列的順序是分開的。
  5. 亂數打亂正確答案的位置,目前都是把正確答案推到最前面,取得result後用concat連接question這個陣列,再打亂一次排序,成為result2
//HTML
#app 
  h2 我的名字是{{name}}
  ul
    li(v-for="word in words", v-on:click="click(word.english)") //讓console顯示出滑鼠點擊到哪個英文單字
      span {{word.english}} {{word.cata}} {{word.trans}}
//Javascript
var vm = new Vue({
  el: '#app',
  data: {
    name: "Frank",
    catas: ["a","b","c","d"],
    words: list4
  },
  methods:{
    click(word){
      console.log("click",word)
    },
    getOptions(question){
      let result = this.words.filter(
      word => word.cata == question.cata).filter(
      word => word.english[0] == question.english[0]).filter(
      word => word.english !== question.english
      ).slice().sort((a,b)=>Math.random()-0.5).slice(0,4)
      let result2 = result.concat([question]).slice().sort((a,b)=>Math.random()-0.5)
      return result2
    }
  }
})

註:concat只能做陣列與陣列的連接。

4. 建立一個出題目按鈕,產生新題目

methods新增pick(),在data中定義還沒開始之前question: null,用this存取本身的資料屬性,隨機選取陣列裡的其中一個字,記得因為index須為整數所以加上parseInt

//HTML
#app 
  button(@click="pick") 出題囉
  h2(v-if="question") {{ question.english }}
//CSS
html,body
  background-color: #222
  color: #fff
//Javascript
var vm = new Vue({
  el: '#app',
  data: {
    ...
    question: null
  },
  methods:{
    click(word){
      console.log("click",word)
    },
    pick(){
      this.question = this.words[parseInt(Math.random()*this.words.length)]
    },
    getOptions(question){
      ...
    }
  }
})
英文2000字選擇題互動網頁:步驟四,出題按鈕
英文2000字選擇題互動網頁:步驟四,出題按鈕

5. 建立選項

把單字的中文抓出來印成題目,同時也把選項抓出來存取,一開始會是空的陣列所以在data定義options: [],再在pick()中多加一行程式碼把產生的新題目裝回去。

//HTML
#app 
  button(@click="pick") 出題囉
  h2(v-if="question") {{ question.trans }}
    ul
      li(v-for="option in options") {{option.english}}
//CSS中要先把li的樣式暫時註解掉
//Javascript
var vm = new Vue({
  el: '#app',
  data: {
    ...
    question: null,
    options: []
  },
  methods:{
    click(word){
      ...
    },
    pick(){
      this.question = this.words[parseInt(Math.random()*this.words.length)]
      this.options=this.getOptions(this.question)
    },
    ...
  }
})
英文2000字選擇題互動網頁:步驟五,建立選項
英文2000字選擇題互動網頁:步驟五,建立選項

6. 判斷答案正確與否

比較簡單的做法是在產生資料時同時附加他是否正確的資訊在其中,我們複製一份新的question避免影響原本的,在let result2前面加上let questionClone = JSON.parse(JSON.stringify(question)),再把帶有正確與否屬性的物件混到原有的選項中,把questionClone作為判斷的正確答案,而result2中原本的question也要記得替換成questionClone

methods新增check(option),點擊選項時如果正確,console印出correct、不正確則印出wrong,回答完後再重新出題this.pick()

//HTML
...
ul
  li(v-for="option in options",
     @click="check(option)") {{option.english}}
//Javascript
...
  methods:{
    click(word){
      console.log("click",word)
    },
    check(option){
      if (option.correct){
        console.log("correct")
      }else{
        console.log("wrong")
      }
      this.pick() //點選答案無論對錯都會換下一題
    },
    pick(){
      ...
    },
    getOptions(question){
      let result = this.words.filter(
      word => word.cata == question.cata).filter(
      word => word.english[0] == question.english[0]).filter(
      word => word.english !== question.english
      ).slice().sort((a,b)=>Math.random()-0.5).slice(0,4)
      let questionClone = JSON.parse(JSON.stringify(question))
      questionClone.correct=true
      let result2 = result.concat([questionClone]).slice().sort((a,b)=>Math.random()-0.5)
      return result2
    }
  }
})
英文2000字選擇題互動網頁:步驟六,console顯示出選擇了正確或錯誤答案
英文2000字選擇題互動網頁:步驟六,console顯示出選擇了正確或錯誤答案

7. 增加答題分數計算機制以及顯示正確或錯誤

要增加答題分數grade的計算機制,首先在data中定義grade: 0,在check(option)中如果答對了就加一分this.grade++,並在HTML中顯示。

//HTML
#app 
  h3 Score:{{grade}}
  ...
//Javascript
...
  data: {
    ...
    grade: 0
  },
  methods:{
    click(word){
      ...
    },
    check(option){
      if (option.correct){
        console.log("correct")
        this.grade++
      }else{
        console.log("wrong")
      }
      this.pick()
    },
    pick(){
      ...
    }
  }

目前答題正確與否只能靠分數是否有增加得知,要改成更直觀一點,點擊選項時如果正確,在題目右邊會印出correct並累加分數this.grade++、不正確則印出wrong,我們使用一個預設是空字串的變數status去儲存這個資訊,status設定過1秒後消失,回答完後再重新出題this.pick()

通常使用者在進到介面時題目已經出好了,答題後會自動更新,所以頁面剛載入時便自動執行一次pickmounted代表Vue已經準備好可以幫忙計算資料了,所以跟methodsdatael在同一層級。

//HTML
#app 
  .container
    .row
      .col-sm-12
        h3 Score:{{grade}}
        h2(v-if="question") Q: {{question.trans}}
          .status {{status}}
        ul 
          li(v-for="option in options", @click="check(option)") {{option.english}}
//Javascript
var vm = new Vue({
  el: '#app',
  data: {
    name: "Frank",
    catas: ["a","b","c","d"],
    words: list4,
    question: null,
    options: [],
    status: "",
    grade: 0
  },
  mounted(){
    this.pick()
  },
  methods:{
    click(word){
      console.log("click",word)
    },
    check(option){
      if (option.correct){
        this.status =("correct")
        this.grade++
      }else{
        this.status = ("wrong")
      }
      setTimeout(()=>{
        this.status=""
        this.pick()
      },1000)
      this.pick()
    },
    pick(){
      this.question = this.words[parseInt(Math.random()*this.words.length)]
      this.options = this.getOptions(this.question)
    },
    getOptions(question){
      let result = this.words.filter(
      word => word.cata == question.cata).filter(
      word => word.english[0] == question.english[0]).filter(word => word.english !== question.english).slice().sort((a,b)=>Math.random()-0.5).slice(0,4)
      let questionClone = JSON.parse(JSON.stringify(question))
      questionClone.correct = true
      let result2 = result.concat([questionClone]).sort((a,b)=>Math.random()-0.5)
      return result2
    }
  }
})
英文2000字選擇題互動網頁:步驟七,增加答題對錯的顏色回饋

8. 設計畫面及加入動畫

完成骨幹後我們來稍微美化一下吧!在codepen設定CSS的地方引入Bootstrap和Animate.css,把DOM元素放到container內,讓版面豐富一些可以加入hover的滑鼠互動效果和答對或答錯時相對應的變色效果。

變色效果我們可以利用Vue的特性來製作──透過判斷式給予class,也就是判斷當前的status為correct或wrong,如果是correct則顯示綠色、wrong則顯示橘色,必須注意的是由於其他四個錯誤答案的status同時都會是wrong,所以要多下一個判斷點currentOption記錄只有點選的這個選項是wrong時才顯示橘色。在data定義currentOption: {}methodscheck(option)的加入條件this.currentOption = option

英文2000字選擇題互動網頁:步驟八,增加答題對錯的顏色回饋
英文2000字選擇題互動網頁:步驟八,增加答題對錯的顏色回饋

淡入的效果我們則透過Animate.css和Vue的key來製作,每次物件重新產生時都帶有新的english值,所以我們給予的key值也會不一樣,這樣他的animated.fadeIn效果就會重新被載入。

//HTML
#app 
  .container
    .row
      .col-sm-12
        h3 Score:{{grade}}
        h2.animated.fadeIn(v-if="question",:key="question.english") Q: {{question.trans}}
          .status {{status}}
        ul.animated.fadeIn(:key="question.english")
          li(v-for="option in options", @click="check(option)",:class="{correct: status=='correct'&& option.correct, error:status=='wrong' && currentOption.english==option.english}") {{option.english}}
//CSS
ul
  list-style: none
  padding: 0
  li
    padding: 10px
    margin-top: 20px
    border: 1px solid white
    cursor: pointer
    font-size: 30px
    transition: .5s
    &:hover
      background-color: rgba(white,0.1)
    &.correct
      background-color: #38d138
    &.error
      background-color: #ff7332
      
.status 
  float: right
//Javascript
...
var vm = new Vue({
  el: '#app',
  data: {
    name: "Frank",
    catas: ["a","b","c","d"],
    words: list4,
    question: null,
    currentOption: {}, //給予{}以防報錯
    options: [],
    status: "",
    grade: 0
  },
  mounted(){
    this.pick()
  },
  methods:{
    click(word){
      console.log("click",word)
    },
    check(option){
      this.currentOption = option //加入條件
      if (option.correct){
        this.status =("correct")
        this.grade++
      }else{
        this.status = ("wrong")
      }
      setTimeout(()=>{
        this.status=""
        this.pick()
      },1000)
    },
    pick(){
      this.question = this.words[parseInt(Math.random()*this.words.length)]
      this.options = this.getOptions(this.question)
    },
    getOptions(question){
      let result = this.words.filter(
      word => word.cata == question.cata).filter(
      word => word.english[0] == question.english[0]).filter(word => word.english !== question.english).slice().sort((a,b)=>Math.random()-0.5).slice(0,4)
      let questionClone = JSON.parse(JSON.stringify(question))
      questionClone.correct = true
      let result2 = result.concat([questionClone]).sort((a,b)=>Math.random()-0.5)
      return result2
    }
  }
})

以上就是這次的英文2000字即時互動小遊戲網頁的製作介紹拉,這次講解了許多Vue的基礎概念與用法,如果是剛開始接觸Vue的朋友很適合拿來小練身手唷!我們下次再見啦~

成品請參考這邊 👉🏻 https://codepen.io/frank890417/pen/RygVde

英文2000字選擇題互動網頁成果

重點回顧:

  1. 利用filter()、sort()、slice()整理與排序資料
  2. 宣告式渲染的使用方式,資料與function的對應關係
  3. 動態判斷class製作css animation效果

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

打好基本的互動網頁基礎之後,可以進階動畫互動網頁特效入門(JS/CANVAS),紮實掌握JavaScript 程式與動畫基礎以及進階互動,整合應用掌控資料與顯示的Vue.js前端框架,完成具有設計感的互動網站。長達3085分鐘,超過60個精緻範例與400張的投影片以上,以及四個加碼單元vue-cli、GSAP、D3、Three.js的投影片,成為hahow上最長的課程。

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

墨雨設計banner

這篇文章 Vue.js入門:英文2000字互動遊戲網頁 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
用Vue.js做快速換色與整理的便利貼牆吧!(下)(直播筆記) https://creativecoding.in/2021/07/09/%e7%94%a8vue-js%e5%81%9a%e5%bf%ab%e9%80%9f%e6%8f%9b%e8%89%b2%e8%88%87%e6%95%b4%e7%90%86%e7%9a%84%e4%be%bf%e5%88%a9%e8%b2%bc%e7%89%86%e5%90%a7-%e4%b8%8b/ Fri, 09 Jul 2021 02:31:00 +0000 https://creativecoding.in/?p=1178 需要發想靈感、紀錄個人代辦清單,或和他人討論嗎?製作一個能夠自由編輯、增刪、變色、拖曳編排的便利貼牆網頁,多個願望一次滿足。下集將便利貼牆美化、功能變得更完善,更連結firebase即時資料庫,再多張便利貼也不怕。

這篇文章 用Vue.js做快速換色與整理的便利貼牆吧!(下)(直播筆記) 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
上一篇我們製作了可以新增、拖曳、換顏色的簡易版本便利貼,這次除了調整細節與動畫外,也會串接Firebase資料庫,讓便利貼牆可以多人同時編輯與更新。

用Vue.js做快速換色與整理的便利貼牆吧!(下)完成圖
用Vue.js做快速換色與整理的便利貼牆吧!(下)完成圖

這次的教學將有以下幾個重點,主要聚焦在既有功能的優化與新功能的添加:

  1. 點擊便利貼文字時因滑鼠與左上角距離設定的關係,會造成距離差而產生的跳動
  2. 加入刪除便利貼的功能
  3. 加入新增或刪除便利貼時,放大縮小的transition
  4. 修改顏色的控制列需和正在使用中的便利貼位置相對應(上次我們處理的方式是都先暫時放在畫面右手邊,這次把它修改得人性化一些)
  5. 編輯文字和多行文字時呈現的大小
  6. 串接Firebase保存資料,即時整理與更新

我們將以Code Pen做為本次實作的平台,這是一個可以在創作的同時即時看到程式碼運作狀況的線上程式碼編輯器,只要簡單註冊就可以使用囉!

如果想搭配直播影片一起實作,請往這邊走

修正點擊便利貼文字造成的距離差

首先fork一份上一次的檔案,fork就像是複製,這樣接續修改也不會動到最初的原始檔唷。

接下來我們在CSS的.postit裡面加上.textpointer-events: none,讓他停止觸發任何的點選事件,在製作滿版圖面但不希望阻礙滑鼠事件時,可以使用這種處理方式(比如:市面網站常見透過hamburger選單收合的滿版menu)。

修正點擊便利貼文字造成的距離差
修正點擊便利貼文字造成的距離差

加入刪除便利貼功能

刪除的概念可以想成「從包著便利貼id的陣列中,運用語法 splice() 切掉該張便利貼的id」。在colorList中新增刪除按鈕如下:

button.btn(@click="postits.splice(pid,1)") 刪除

新增 / 刪除的transition

接著加入新增和刪除的動畫,我們使用vue的transition group處理。在使用上有以下幾點特性:

  1. 在HTML新增,transition-group(name="fade"),注意transition Group需要包成一個div使用,這邊用tag="ul"處理,而便利貼為li
  2. Group內的每個物件都需要名字,才知道控制動畫的元件範圍,給他:key="pid",每張便利貼都有個獨一無二的id

利用vue官方提供的效果fade稍微調整一下語法,加入scale讓便利貼有從小變大、長出來的效果。

//HTML
#app
  transition-group(name="fade", tag="ul")
    li.postit(v-for="(p,pid) in postits",
            //為每張便利貼加上一個id
            :key="pid",
            :style="postitCss(p)",
            @mousedown="selectId($event,pid)")
      .text {{p.text}}
//CSS
.fade-enter-active, .fade-leave-active 
  transition: .5s

.fade-enter, .fade-leave-to 
  opacity: 0
  transform: scale(0.1)
新增 / 刪除的transition
新增 / 刪除的transition

調整顏色控制列的位置

為了讓修改顏色的功能更人性化一些,我們把.colorList整包移到.postit裡面,用position: absolute定位在便利貼下方。這時你會發現熟悉的點擊距離差問題又回來了,但因爲修改顏色和刪除的機制也是透過滑鼠,所以無法使用之前的方法來解決。

先前拖曳功能的設定為——點擊便利貼時,將該張便利貼設定id為0,當滑鼠移動時同步更新設定。現在把控制列跟便利貼拆成不同部分,也就是說點擊控制列時不進行id的設定。

selectId的event中判斷source element是否含有blockbtn,如果沒有,則進行id的設定,如果有,則nowId=-1

//CSS
.colorList
  position: absolute
  bottom: -80px
  display: flex
  flex-direction: row
  .block
    margin-right: 10px
//JavaScript
selectId(evt,id){
  console.log(evt)
  let isBlock = evt.srcElement.classList.contains('block')
  let isBtn = evt.srcElement.classList.contains('btn')
  if (!isBlock && !isBtn ){
    this.nowId=id //滑鼠點下去
    this.startMousePos = {
      x: evt.offsetX,
      y: evt.offsetY
    }
  }else{
    this.nowId=-1
  }
}
調整顏色控制列的位置
調整顏色控制列的位置

文字編輯和多行文字時呈現的大小

如同調整顏色的控制列,我們希望在編輯文字部分可以有更好的使用者體驗,透過點擊該張便利的「編輯」按鈕即可修改。在Vue裡增加 setText 這個方法,利用語法 prompt() 跳出修改視窗,並在input欄位顯示原始的文字(透過抓取pid知道是哪張便利貼、上面有甚麼文字):

//HTML
//新增編輯按鈕
button.btn(@click="setText(pid)") 編輯
//JavaScript
methods:{
  ...
  setText(pid){
    //彈出視窗修改文字
    let text = prompt("請輸入新的文字", this.postits[pid].text)
    //送出之後再便利貼上更新文字
    if (text){
      this.postits[pid].text=text
    }
  }
}
文字編輯和多行文字時呈現的大小
文字編輯和多行文字時呈現的大小

連接Firebase資料庫

接下來進入今天的重頭戲——串接Firebase資料庫。Firebase是Google提供的雲端開發平台,協助 開發者在雲端快速建置後端服務,提供即時資料庫。這種noSQL(非關聯式)類型的資料後端平台可能是未來的趨勢,noSQL代表你不會用像select all member這種特殊的資料查詢語法,他就是一張樹狀圖,把所有東西塞進去,所以你可以看到便利貼在頁面上即時地移動與資料修改,在Firebase資料庫裡也可以看到頁面上的改動。

首先前往Firebase的控制台,新增一個for便利貼的專案。

接著選擇Realtime Database,在專案中新增child如下:

tips: 右邊的值必須要先輸入一個default數值,之後有資料存入時便會被取代掉了。

接著引入我們的codepen網頁,在codepen引進CDN,再初始化資料庫。

Step 1:進到Overview,點選「網頁」。

Step 1:進到Overview,點選「網頁」。
Step 1:進到Overview,點選「網頁」。

Step 2:將Firebase新增至codepen。CDN為第一個script內的src,初始化config為下方的firebaseConfig。

Step 2:將Firebase新增至codepen,初始化config為下方的firebaseConfig。

Step 3:引入CDN,第一個script內的src貼入codepen settings。

Step 3:引入CDN,第一個script內的src貼入codepen settings。
Step 3:引入CDN,第一個script內的src貼入codepen settings。

Step 4:初始化資料庫,將Step 2裡面第二個script中的程式碼複製貼在我們JS程式碼的最上方。

Step 4:初始化資料庫,將Step 2裡面第二個script中的程式碼複製貼在我們JS程式碼的最上方。
Step 4:初始化資料庫,將Step 2裡面第二個script中的程式碼複製貼在我們JS程式碼的最上方。

接著透過Firebase手動新增一張便利貼如下,注意資料的層級,尤其是代表便利貼位置的x, y是在pos下面。

透過Firebase手動新增一張便利貼
透過Firebase手動新增一張便利貼

再把codepen跟建立好的firebase資料庫串接,並監聽他的value做即時更新,可以看到我們剛剛手動在資料庫新增的便利貼。Firebase語法可參考官方文件

//JavaScript
var postitsRef = firebase.database().ref("postits2"); //建立連結
  postitsRef.on('value', (snapshot)=>{
   vm.postits = snapshot.val() //即時更新
  })

註:firebase串接語法已更新成firebase.database().ref(),直播內容的firebase.database.ref()為舊版。

把codepen跟建立好的firebase資料庫串接,並監聽他的value做即時更新
把codepen跟建立好的firebase資料庫串接,並監聽他的value做即時更新

遠端新增 / 刪除便利貼

可以透過資料庫新增並呈現在頁面上後,我們這邊試試透過codepen push便利貼進去資料庫,修改methods addPostits的地方,讓他不是新增在vm這邊而是postitsRef

//JavaScript
addPostits(){
  postitsRef.push(
    {
      text: "文字",
      color: "yellow",
      pos: {x: 200+Math.random()*100, y: 200+Math.random()*100 }
    }

我們希望能刪除特定便利貼的節點,也就是postitsRef下的子結點,記得也需修改HTML刪除按鈕的語法為@click="deletePostit(pid)",並在JS新增以下methods:

deletePostit(pid){
  postitsRef.child(pid).remove();
}

同步顏色 / 文字 / 拖移位置

除了更新頁面上便利貼的位置,也同步更新遠端資料庫的位置,所以在mousePos抓這張便利貼this.nowId,設定set更新遠端資料庫的值。

//JavaScript
postitsRef.child(this.nowId).set(this.postits[this.nowId])

同步文字的邏輯也是類似的,在setText做完本地更新後,也一起更改資料庫的文字。

//JavaScript
postitsRef.child(pid).set(this.postits[pid])

最後一個則是顏色,我們原先是讓便利貼的顏色等於顏色的名字p.color=color.name,現在為了更新遠端資料 ,我們把它包成一個function叫setColor。在methods定義setColor的作用,概念和setText類似。

//HTML
.colorList
  .block(v-for="color in colorList",
     :style="{backgroundColor: color.color}",
         //修改成成為setColor function
     @click="setColor(pid, color.name)")
//JavaScript
setColor(pid,colorname){
  this.postits[pid].color=colorname
  postitsRef.child(pid).set(this.postits[pid])
},

以上就是這次的直播內容,主要聚焦於功能的優化與資料庫的串接,後面firebase的部分對於初次接觸的人可能會需要一段時間的摸索,但只要成功串接起來、再多研究一下文件,就會比較好入手。

步驟總結

這次的直播內容比較複雜,所以在最後來個總重點整理一下。在上篇我們先建立便利貼的基礎,從樣式雛形到基本資料處理,可以分為以下幾個重點:

1. 便利貼樣式與資料處理 – 建立便利貼架構,色票、文字樣式和文字位置
2. 加上滑鼠互動事件 – 紀錄滑鼠移動的位置並儲存在evt,運用nowId判斷滑鼠在哪一張便利貼的範圍裡面
3. 新增便利貼與修改顏色 – 做一個button點擊觸發function addPostits,在addPostits推入新的陣列

下篇的部分我們著重在既有功能的微調、更精緻化,還有資料庫的串接:

1. 修正點擊便利貼文字造成的距離差 – pointer-events: none停止觸發任何的點選事件
2. 刪除便利貼功能 – 運用語法splice()切掉該張便利貼的id
3. 新增 / 刪除的transition – 使用vue的原生transition group處理,transition-group(name=”fade”)
4. 調整顏色控制列的位置 – 把colorList整包移postit裡面,用position: absolute定位在便利貼下方
5. 文字編輯和多行文字時呈現的大小 – 利用語法prompt()跳出修改視窗,優化文字編輯的使用者體驗
6. 連接Firebase資料庫 – 在Firebase建立專案與Realtime Database,與Codepen資料連接
7. 同步顏色 / 文字 / 拖移位置 – 在mousePos抓特定便利貼this.nowId,設定set更新遠端資料庫的值

課程推薦

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

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

那我們下次再見啦👋👋👋

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

墨雨設計banner

這篇文章 用Vue.js做快速換色與整理的便利貼牆吧!(下)(直播筆記) 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
用Vue.js做快速換色與整理的便利貼牆吧!(上)(直播筆記) https://creativecoding.in/2021/07/05/%e7%94%a8vue-js%e5%81%9a%e5%bf%ab%e9%80%9f%e6%8f%9b%e8%89%b2%e8%88%87%e6%95%b4%e7%90%86%e7%9a%84%e4%be%bf%e5%88%a9%e8%b2%bc%e7%89%86%e5%90%a7-%e4%b8%8a/ Mon, 05 Jul 2021 01:31:00 +0000 https://creativecoding.in/?p=1154 需要發想靈感、紀錄個人代辦清單,或和他人討論嗎?製作一個能夠自由編輯、增刪、變色、拖曳編排的便利貼牆網頁,多個願望一次滿足。上集我們使用Pug、Sass及Vue.js刻出便利貼的基本功能。

這篇文章 用Vue.js做快速換色與整理的便利貼牆吧!(上)(直播筆記) 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
在需要發想靈感的職業日常中,便利貼常常作為靈感紀錄與討論道具,有著不可取代的重要性,但困擾的是常常黏性一過,原先貼得整整齊齊的便利貼只要一有風吹草動,就如同十二月的雪一樣翩翩飛舞。如果可以把這些小傢伙電子化,豈不是美事一樁?今天我們就要用Vue.js來實作可以快速地新增、輸入內容、換色、刪除甚至拖曳編排的便利貼牆。這一次的案例因為比較複雜,所以切成兩篇來做,請上下兩篇搭配一起服用唷~

用Vue.js做換色與快速整理的便利貼牆吧!(上)成品圖
用Vue.js做換色與快速整理的便利貼牆吧!(上)成品圖

我們將以Code Pen做為本次實作的平台,這是一個可以在創作的同時即時看到程式碼運作狀況的線上程式碼編輯器,只要簡單註冊就可以使用囉!

如果想搭配直播影片一起實作,請👉🏻往這邊走

了解架構

在開始之前我們先來概念性的發想,便利貼的結構設計可分為以下這些方向:

  1. 儲存的資料種類—文字(顏色名稱)和色票
  2. 文字呈現方式—文字在便利貼上應該滿版呈現,隨著文字多寡而動態調整大小
  3. 拖曳功能—滑鼠點擊在便利貼上時紀錄初始座標,到結束時動態計算中間的滑動呈現
  4. 小功能—切換文字、刪除等功能

首先在Code Pen上開一個新的pen,將HTML的預處理器設定成Pug、CSS的預處理器設定成Sass、JS的CDN掛入Vue。

準備好Code Pen中的環境
準備好Code Pen中的環境

便利貼樣式與資料處理

接著我們開始從第一張便利貼刻起,便利貼的結構簡單來說就是一個裝有文字的框框,文字資料我們先暫時寫死,稍後再用Vue.js動態更新。

在HTML給他一個容器名為postit裡面裝一些文字text,接著在CSS設定他的樣式如下,如次便能得到一個文字在中間的基礎白色便利貼:

@import url('https://fonts.googleapis.com/css2?family=Noto+Sans&display=swap')
*, *:before, *:after
  border: solid 1px //之後再把邊線取消掉
  font-family: 'Noto Sans', sans-serif
@mixin size($w, $h:$w)
  width: $w
  height: $h

$colorBlack:#3E3A39

html, body
  // background-color: $colorBlack //背景色,設定樣式時先拿掉方便識別
  
.postit
  +size(240px)
  font-size: calc( 240px / 4 - 5px ) //便利貼寬÷字數-預留空隙
  display: flex
  justify-content: center
  align-items: center
第一章便利貼

接下來替便利貼上一些活潑的顏色,讓他變得好玩一些,同時調整字的顏色跟上一些陰影來增加設計的細緻度,我們的第一張便利貼就完成了。使用的色票和陰影處理如下,大家也可以自由選擇喜歡的顏色唷:

//修改字體顏色不要太死黑
color: #44403F

//便利貼顏色
$colorYellow: #FFEB67
$colorBlue: #A5D8D6
$colorRed: #EF898C
$colorGreen: #CBE196

.postit
	background-color: $colorYellow
  letter-spacing: 5px
  font-weight: 500
  box-shadow: 15px 10px 40px rgba(black,0.4)
調整便利貼的顏色、陰影
調整便利貼的顏色、陰影

接下來進行資料的處理,在JavaScript裡給予一組陣列名為postits,裡面裝便利貼會用到的參數textcolorposition。接著新增一個new Vue並指定作用範圍為#app,抓出物件便利貼跟他是第幾張(p,pid) in postits,將文字套進{{p.text}}。用左下角的Console檢查資料是否連接好,先輸入vm之後,再輸入vm.$data.postits[0].text="temp"資料,如果出現”temp”替換掉原先的文字「都市更新」就代表成功。

//Vue.js
var vm = new Vue({
  el: '#app',
  data: {
    postits: [
      {
        text: "都市更新",
        color: "yellow",
        pos: {x:20, y:0}
      }
    ]
  }
})
可替換內文的便利貼
可替換內文的便利貼

加上滑鼠互動事件

為了增加自由度,我們來替便利貼加上拖曳的功能。拖曳代表著我們必須在螢幕上做絕對定位,當在拖動物件時,左上角的距離不斷地重複更新,要做到這項事情,首先我們必須將資料與定位綁定起來。

在CSS的.postit裡加上position: absolute,有時在Vue我們會動態地加上style,但這容易造成HTML程式碼裡拖了一長串反而不好閱讀,所以像這種共用性高的style可以考慮直接在CSS做設定。

接著在JS設定style的[methods](<https://cythilya.github.io/2017/04/17/vue-methods-and-event-handling/>)如下,然後帶入HTML的postit後,可在Console透過剛剛上面的方法改變文字來測試字型大小的調整是否成功。

methods:{
  postitCss(p){
    return {
      left: p.pos.x+"px", //動態地更新便利貼的位置
      top: p.pos.y+"px",
      'font-size': ((240-30) / p.text.length) +'px' //根據文字長度動態設定大小
    }
  }
}
修改CSS與JS綁定文字資料及位置,以便加上拖曳功能
修改CSS與JS綁定文字資料及位置,以便加上拖曳功能

顏色的設定跟文字的方法差不多,在JS的data裡面新增一個名為colorList的陣列,裡面塞入我們剛剛的色票與相對應的名字:

colorList: [
  {
    name:"yellow",
    color: "#FFEB67"
  },{
    name:"blue",
    color: "#A5D8D6"
  },{
    name:"red",
    color: "#EF898C"
  },{
    name:"green",
    color: "#CBE196"
  },{
    name:"black",
    color: "#3E3A39"
  }
],

接著在下方的methods裡return他的值。在Vue裡面你要抓他的值可以直接用this指向,定義條件用find過濾符合的資料。

//JavaScript
'background-color': this.colorList.find(o=>o.name==p.color).color

這時我們可以製作一個control pannel來快速調整便利貼內的文字跟顏色,省去一直打開Console輸入指令測試的重複步驟。在HTML新增一個ul放入li和輸入欄位inputinput分別對應到p.text抓取輸入文字內容和p.color選擇便利貼顏色,再設定他的css。

//HTML
ul.datalist
  li(v-for="(p,pid) in postits")
    input(v-model="p.text")
    input(v-model="p.color")
//css
.datalist
  position: fixed
  right: 20px
  top: 20px
  width: 30%
設定便利貼顏色
設定便利貼顏色

前面做了資料和定位的綁定後滑鼠靜止的部分搞定,接下來要做滑鼠移動時的行為,這部分比較複雜可能需要多一點時間理解唷。我們在整個畫面上紀錄滑鼠移動的位置並儲存在evt內,右鍵檢查裡面有一個參數offset代表滑鼠距離左上角的相對位置。

小筆記:evt代表event,每次滑鼠移動時會觸發的一連串事件,包括滑鼠位置、點擊放開等,都會儲存在這裏面。

//JavaScript
window.onmousemove = (evt)=>{
  //滑鼠移動時,將最新位置記錄到vue中
  // console.log(evt)
  vm.postits[0].pos.x=evt.pageX //設定第一張便利貼的x距等於滑鼠在頁面上的x距
  vm.postits[0].pos.y=evt.pageY //設定第一張便利貼的y距等於滑鼠在頁面上的y距
}
滑鼠移動更新其在Vue.js內的位置

現在便利貼會跟著滑鼠移動,但我們希望移動是滑鼠點擊觸發之後才發生的。所以需要在data的地方多儲存一個nowId: -1nowId代表滑鼠點擊但還沒放開時,滑鼠在哪一張便利貼的範圍裡面。

我們在HTML裡加上@mousedown="selectId(pid)"並綁定點擊事件放在methods裡面,新增滑鼠移開的事件。為了方便識別可以把nowId印在畫面上。

//JavaScript
var vm = new Vue({
  ...	
  data: {
    ...
    nowId: -1
  },
  methods: {
    ...
    selectId(id){
      console.log(id)
      this.nowId=id //滑鼠點擊時選擇該張便利貼
    }
  }
})

//滑鼠移動時,將最新位置記錄到vue中
window.onmousemove = (evt)=>{
  // console.log(evt)
  if (vm.nowId!= -1){
    vm.postits[vm.nowId].pos.x=evt.pageX //抓取第nowId張便利貼的x距等於滑鼠在頁面上的x距
    vm.postits[vm.nowId].pos.y=evt.pageY //抓取第nowId張便利貼的y距等於滑鼠在頁面上的y距
  }
}

window.onmouseup = (evt)=>{
  vm.nowId = -1 //滑鼠未點擊時沒有選擇任何便利貼
}
//HTML
#app
  .postit(v-for="(p,pid) in postits",
          :style="postitCss(p)",
          @mousedown="selectId(pid)")
    .text {{p.text}}


ul.datalist
//把nowId印在畫面上
  li
    h1(style="color: white") {{nowId}}
  ...
設定滑鼠點擊與未點擊時的判別與動作
設定滑鼠點擊與未點擊時的判別與動作

單用window.onmousemove = (evt)監測時可能會有些狀況,我們希望當滑鼠有變動時,就在Vue裡偵測資料的變動並針對位置做更新,所以我們新增watch偵測是否有選擇便利貼,如果有就把這張便利貼抓出來。這張便利貼會等於所有便利貼的第nowId個,知道第幾張後就去設定他的位置。

var vm = new Vue({
  data: {
    ...
    mousePos: {
      x:0, y:0
    }
  },	
  watch: {
    mousePos(){
      if (this.nowId!= -1){
        let nowPostit = this.postits[this.nowId]
        nowPostit.pos.x = this.mousePos.x
        nowPostit.pos.y = this.mousePos.y
      }
      console.log(this.mousePos)
    },
  ...
})

window.onmousemove = (evt)=>{
  // console.log(evt)
  vm.mousePos = {x: evt.pageX, y: evt.pageY}
  //if (vm.nowId!= -1){
    //vm.postits[vm.nowId].pos.x=evt.pageX
    //vm.postits[vm.nowId].pos.y=evt.pageY
  //}
  
}

記得把作用範圍撐開跟window一樣大,不然會無法運作。

//css
html, body, #app
  background-color: $colorBlack
  padding: 0
  margin: 0
  overflow: hidden
  +size(100%)

拖曳的功能就完成了,但這時點擊便利貼的右下角時會有一些偏移、跳一下,我們可以記錄這個偏移量並加上點擊的位置,這樣一減一加之後我們就可以讓他乖乖待在位置上。在HTML的selectId新增$event,也記錄第一個點下去的位置startMousePos

@mousedown="selectId($event,pid)"
var vm =new Vue({
  ...
  data: {
    ...
    startMousePos: {
      x: 0,
      y: 0
    }
  }
  ...
  methods: {
    ...
    selectId(evt,id){
      console.log(id)
      this.nowId=id //滑鼠點下去
      this.startMousePos = {
        x: evt.offsetX,
        y: evt.offsetY
      }
    }
  }
})

watch減掉偏移量,有滑鼠移動而且我們判斷當下那張便利貼存在的時候。這樣拖曳功能就大功告成啦。

watch: {
  mousePos(){
    if (this.nowId!= -1){
      let nowPostit = this.postits[this.nowId]
      nowPostit.pos.x = this.mousePos.x-this.startMousePos.x
      nowPostit.pos.y = this.mousePos.y-this.startMousePos.y
    }
    console.log(this.mousePos)
  }

如果想要將滑鼠拖曳的效果變得更滑順,可以在CSS的.postit裡面加上cursor: pointer試試效果。

新增與修改顏色

接下來我們來做「新增」的功能,做一個button點擊觸發functionaddPostits。在addPostits推入新的陣列,位置給予亂數Math.random才不會覆蓋在原有的便利貼上。

新增便利貼功能
新增便利貼功能
//HTML
  ul.datalist
    ...
    button(@click="addPostits") +新增便利貼

最後做個改顏色的功能,透過點擊動態渲染的小方塊來改變便利貼的顏色,參考如下。

ul.datalist
  li
    ...
    .colorList
      .block(v-for="color in colorList",:style="{backgroundColor: color.color}", @click="p.color=color.name")
//css
.block
  +size(30px)
  background-color: #fff
  display: inline-block
//JavaScript
methods: {
  //直接從現有顏色清單裡選取一顏色
  getColor(name){
    return this.colorList.find( o=>o.name==name)  
  }
  ...
}
製作顏色小方框,方便直接更換便利貼顏色
製作顏色小方框,方便直接更換便利貼顏色

總結

以上就是前半段的便利貼教學,我們先建立便利貼的基礎,從樣式雛形到基本資料處理,可以分為以下幾個重點:

1. 便利貼樣式與資料處理 – 建立便利貼架構,色票、文字樣式和文字位置

2. 加上滑鼠互動事件 – 紀錄滑鼠移動的位置並儲存在evt,運用nowId判斷滑鼠在哪一張便利貼的範圍裡面

3. 新增便利貼與修改顏色 – 做一個button點擊觸發function addPostits,在addPostits推入新的陣列

下一次我們會接續進行刪除、背景調整、縮放動畫等的功能,我們下次見啦👋👋👋

老闆的互動網頁課程

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

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

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

墨雨設計banner

這篇文章 用Vue.js做快速換色與整理的便利貼牆吧!(上)(直播筆記) 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>