【Canvas真好玩】從黑客帝國開始

  • 2019 年 11 月 13 日
  • 筆記

前言

筆者之前有一段時間一直在學習Canvas相關的技術知識點,通過參考網上的一些資料文章,學著利用簡單的數學和物理知識點實現了一些比較有趣的動畫效果,最近剛好翻看到以前的程式碼,所以這次將這些程式碼實踐重新梳理一遍後整理成文,自己鞏固複習的同時,可以和大家一起交流學習。作為【Canvas真好玩】系列的第一篇文章,筆者還是從最經典的黑客帝國開始,在一步一步進行程式碼具體實踐的同時,帶領大家進入神奇的Canvas動畫的世界。

程式碼已上傳至Github,可以拉下來後直接運行,省掉下面的準備工作環節。

效果圖

準備工作

因為之前的程式碼比較久遠,這次打算使用React來重構一遍,還是使用目前使用頻率比較高的create-react-app腳手架來搭建項目,在本地找到合適的項目路徑,然後執行項目初始化命令:

npm install -g create-react-app  create-react-app react-canvas

考慮到後期可能會有一系列的動畫效果,所以為了介面美觀以及方便管理,這裡直接簡單使用下React Ant Design來管理動畫菜單方便切換到不同的動畫,使用react-router-dom來控制路由,同時使用loadable來對路由實現按需載入:

npm install --save antd react-router-dom @loadable/component    // 以下依賴遵循antd官網的高級配置,使用babel-plugin-import實現組件程式碼和樣式的按需載入  npm install --save-dev react-app-rewired customize-cra babel-plugin-import

安裝完成之後修改package.json文件:

/* package.json */  "scripts": {  -   "start": "react-scripts start",  +   "start": "react-app-rewired start",  -   "build": "react-scripts build",  +   "build": "react-app-rewired build",  -   "test": "react-scripts test",  +   "test": "react-app-rewired test",  -   "eject": "react-scripts eject",  +   "eject": "react-app-rewired eject",  }

然後在項目根目錄創建一個 config-overrides.js 用於修改默認配置:

+ const { override, fixBabelImports } = require('customize-cra');    + module.exports = override(  +   fixBabelImports('import', {  +     libraryName: 'antd',  +     libraryDirectory: 'es',  +     style: 'css',  +   }),  + );

到目前為止,項目的目錄結構如下:

├── node_modules  ├── public  │   ├── favicon.ico  │   └── index.html  ├── src  │   ├── App.css  │   ├── App.js  │   ├── App.test.js  │   ├── index.css  │   ├── index.js  │   ├── logo.svg  │   └── serviceWorker.js  ├── .gitignore  ├── config-overrides.js  ├── package.json  ├── package-lock.json  └── README.md

src目錄下有一些在當前項目中不太需要的文件,可以將其刪除,然後在src目錄下創建router目錄用於存放項目路由,views目錄用於存放不同路由下的頁面,通過antd的Layout組件來實現頁面布局,修改後的程式碼如下:

// src -> router -> index.js  import loadable from '@loadable/component';    const routes = [    {          path: '/hacker',          name: '黑客帝國',          component: loadable(() => import(/* webpackChunkName: 'hacker' */ '../views/Hacker')),      }  ];    export default routes;
// src -> views -> Hacker.js  function Hacker() {        const canvasRef = useRef(null);        return (          <canvas ref={canvasRef} style={{background: '#000'}}/>      );  }    export default Hacker;
// src -> App.js  import React, {useState} from 'react';  import {Redirect, Route, NavLink, Switch, withRouter} from 'react-router-dom';  import {Layout, Menu, Icon} from 'antd';  import routes from './router';  import './App.css';    const {Header, Sider, Content} = Layout;    function App({location}) {      const [collapsed, setCollapsed] = useState(false);      const toggle = () => setCollapsed(!collapsed);      return (          <Layout>              <Sider trigger={null} collapsible collapsed={collapsed}>                  <div className="title">Canvas真好玩</div>                  <Menu theme="dark" mode="inline"                        defaultSelectedKeys={[location.pathname.length === 1 ? routes[0].path : location.pathname]}>                      {                          routes.map(route =>                              <Menu.Item                                  key={route.path}>                                  <NavLink                                      to={route.path}                                      style={{color: 'rgba(255,255,255,.65)'}}                                      activeStyle={{color: '#fff'}}                                  >                                      {route.name}                                  </NavLink>                              </Menu.Item>)                      }                  </Menu>              </Sider>              <Layout>                  <Header style={{background: '#fff', padding: 0}}>                      <Icon                          className="trigger"                          type={collapsed ? 'menu-unfold' : 'menu-fold'}                          onClick={toggle}                      />                  </Header>                  <Content                      style={{                          display: 'flex',                          justifyContent: 'center',                          alignItems: 'center',                          margin: '24px 16px',                          padding: 24,                          background: '#fff',                          minHeight: 280,                      }}                  >                      <Switch>                          {                              routes.map((route, i) =>                                  <Route                                      path={route.path}                                      exact={route.exact}                                      render={props => <route.component {...props} router={route.routes}/>}                                      key={i}                                  />                              )                          }                          <Redirect from="/" to="/hacker" exact={true}/>                      </Switch>                  </Content>              </Layout>          </Layout>      );  }    export default withRouter(App);
// src -> index.js  import React from 'react';  import ReactDOM from 'react-dom';  import {BrowserRouter as Router} from 'react-router-dom';  import './index.css';  import App from './App';    ReactDOM.render(      <Router>          <App/>      </Router>,      document.getElementById('root'));
// src -> App.css  #root {      height: 100%;  }    .ant-layout {      height: 100%;  }    .title {      padding: 16px 0;      text-align: center;      color: #fff;      font-size: 24px;      background-color: rgba(0, 0, 0, .2);  }    .trigger {      font-size: 18px;      line-height: 64px;      padding: 0 24px;      cursor: pointer;      transition: color 0.3s;  }    .trigger:hover {      color: #1890ff;  }    .logo {      height: 32px;      background: rgba(255, 255, 255, 0.2);      margin: 16px;  }

至此,我們項目的基本程式碼結構就已經書寫完畢,這裡先貼一張我目前已經完成的頁面效果:

其實也沒有那麼好看,主要是為了方便管理菜單,接下來我們就來一步一步分析實現頁面中炫酷的黑客帝國效果吧。

實現

在程式碼實踐之前,我們先來分析一下黑客帝國的實現細節,在上面的動畫效果中,我們可以知道,動畫其實就是由各種英文字母,數字以及特殊符號實現的一個從上到下的距離偏移效果,所以我們在程式碼中會維護一個集合用於存放所有可能出現的文字。其次,我們可以看出,文字的下墜效果其實是分成了多列的,當然列數會根據Canvas容器的寬度來動態計算。為了實現動畫,我們這裡可以藉助瀏覽器的requestAnimationFrame來保持每秒60幀的流暢度,相信大部分前端人員對這個Api已經不陌生了,不過這裡需要注意以下兩點:

  1. 若想在瀏覽器下次重繪之前繼續更新下一幀動畫,那麼回調函數自身必須再次調用requestAnimationFrame()
  2. 為了提高性能和電池壽命,因此在大多數瀏覽器里,當requestAnimationFrame() 運行在後台標籤頁或者隱藏的iframe里時,requestAnimationFrame() 會被暫停調用以提升性能和電池壽命

通過這個動畫Api我們就可以在每幀的時間內清空當前的Canvas容器狀態,同時計算每個文字的新坐標並進行繪製,我們可以為每列文字的Y軸偏移定義一個初始變數為1,即表示一個字體單位的大小,每次當文字下落一個字體大小的時候,將這個初始變數加1,這樣在下次計算文字坐標的時候,就可以將這個值乘以字體大小從而得出Y軸的坐標,這樣在視覺上就達到了一個文字的下墜效果。這裡需要提一下的是,Canvas的坐標系統和理科領域的笛卡爾坐標系有點不太一樣,採用默認的窗口坐標系統,即原點坐標位於窗口的左上角,沿X軸方向向右為正值,沿Y軸方向向下為正值,在後續計算文字坐標的時候需要注意這裡的區別,其實窗口坐標系統中也是有負值的,只是跑到了螢幕之外,我們一般沒有注意到而已。
笛卡爾坐標系:

窗口坐標系:

關於Canvas其他的知識點和基礎API不是本系列的重點,感興趣的同學可以自行網上查閱下相關資料,Canvas的繪圖API也不是很多,學習門檻不高,很好掌握。基於以上的分析,我們嘗試完善一下Hacker.js中的程式碼:

function Hacker() {        const canvasRef = useRef(null);        useEffect(() => {            // 獲取當前的canvas元素          const canvas = canvasRef.current;            // 獲取canvas上下文,2d表示建立一個二維渲染上下文,當然也有基於WebGL的三維渲染上下文,在本系列中暫不考慮          const context = canvas.getContext('2d');            // 臨時保存canvas的寬高資訊,問了簡便固定800 x 600          const w = canvas.width = 800;          const h = canvas.height = 600;            // 文字顏色          const textColor = '#33ff33';            // 保存所有可能出現的文字          const words = "0123456789qwertyuiopasdfghjklzxcvbnm,./;'[]QWERTYUIOP{}ASDFGHJHJKL:ZXCVBBNM<>?";            // 將文字拆分進一個數組          const wordsArr = words.split('');            // 這裡假設每個文字的字體大小為16px          const font_size = 16;            // 根據字體大小動態計算文字列數          const columns = w / font_size;            // 根據上面的分析,我們創建一個數組保存每列中的文字當前在Y軸上偏移了幾個字體單位          const dropUnits = [];            // 初始化dropUnits,默認值從1開始,而不是0,因為canvas的fillText方法默認是從文字的左下角開始繪製          for (let i = 0; i < columns; i++) {              dropUnits[i] = 1;          }            // 設置上下文的填充色和字體大小          context.fillStyle = textColor;          context.font = `${font_size}px arial`;            function draw() {                // 核心,              // 這裡開始循環每一列,              // 為每一列創建隨機文字,              // 同時根據當前列已經下落了幾個字體大小來設置文字坐標(坐標原點為canvas容器的左上角)              for (let i = 0, len = dropUnits.length; i < len; i++) {                  const text = wordsArr[Math.floor(Math.random() * wordsArr.length)];                  const x = i * font_size;                  const y = dropUnits[i] * font_size;                  context.fillText(text, x, y);                    // 當文字已經超出高度邊界的時候,需要重置當前列下落的字體單位                  if (y > h) {                      dropUnits[i] = 0;                  }                    dropUnits[i]++;              }          }            // 循環執行動畫          (function frame() {              // 此處需要再次調用requestAnimationFrame,注意並不是同步遞歸              window.requestAnimationFrame(frame);                // 在繪製下一幀的文字之前需要清空當前狀態下的所有文字,避免文字被覆蓋              context.clearRect(0, 0, w, h);              draw();          }());      }, []);        return (          <canvas ref={canvasRef} style={{background: '#000'}}/>      );  }

添加以上程式碼之後,我們來看看目前的效果:


這個效果並不是我們理想中的樣子,我們分析一下問題出現的原因,在以上程式碼實現中,draw函數用於繪製文字,如果檢測到文字當前已經超出容器範圍,則會重置dropUnits數組中的值為0,那麼導致的後果就是,dropUnits數組中的每一項都為0,所以每列文字的Y軸起始坐標始終都是相同的,也就造成上面的效果。所以我們只需要想辦法讓Y軸的起始坐標錯開,那麼也就達到了預期的效果了,當然這種錯開也是隨機的,所以就很容易想到使用Math.random方法增加隨機數判斷來實現了,我們對以上程式碼稍作一下修改:

- if (y > h) {  + if (y > h && Math.random() > 0.98) { // 此處增加隨機數判斷,只有滿足條件後才進行重置      dropUnits[i] = 0;  }

我簡單畫了張圖來幫助理解一下這個過程,圖中兩個方塊代表兩個文字,布爾值代表上面程式碼中if條件的結果:

上圖中可以清楚地看到新增了隨機數之後,文字的Y軸坐標產生了差異,修改後的效果如下:

離預期的效果越來越近了,但是這個效果看起來有點生硬,因為我們在每一幀中繪製文字之前,會使用Canvas的clearRect方法將Canvas畫布進行清除,所以文字會瞬間出現在下一個坐標點中,形成這種閃爍效果,類似於馬路上的紅綠燈,在切換顏色之前會將之前的顏色清空,然後瞬間切換。這裡我們換一種思路,我們不使用clearRect方法來清除畫布,而是在每一幀中使用fillRect方法為畫布填充一層淡淡的背景色,以此來實現漸變效果,我們來對程式碼稍作修改:

// 文字顏色  const textColor = '#33ff33';  + // 填充背景色  + const bgColor = 'rgba(0, 0, 0, .1)';    - // 設置上下文的填充色和字體大小  - context.fillStyle = textColor;  - context.font = font_size + 'px arial';  function draw() {      // 將上述兩行程式碼放到此函數中,因為這裡需要重新設置fillStyle      + context.fillStyle = textColor;      + context.font = font_size + 'px arial';  }    // 循環執行動畫  (function frame() {      ...      - // 在繪製下一幀的文字之前需要清空當前狀態下的所有文字,避免文字被覆蓋      - context.clearRect(0, 0, w, h);        + // 在繪製下一幀的文字之前給畫布填充背景色      + context.fillStyle = bgColor;      + context.fillRect(0, 0, w, h);      ...  }());

程式碼修改完畢後趕緊看下效果吧,應該就和本文開頭的效果圖一樣了,至此,就已經使用Canvas完整地實現了黑客帝國效果,還不錯吧。

總結

本文主要是跟大家分享一下使用Canvas來實現炫酷的黑客帝國效果,當然這只是本系列的開篇,後續還會結合簡單的數學和物理知識來實現更加有趣的動畫效果,希望能和大家一起相互討論,互相學習。

交流

今天先分享到這裡,如果大家對Canvas的動畫比較感興趣,可以關注咱們的公眾號,一起交流學習。

文章已同步更新至Github部落格,若覺文章尚可,歡迎前往star!

你的一個點贊,值得讓我付出更多的努力!

逆境中成長,只有不斷地學習,才能成為更好的自己,與君共勉!