29

【Canvas真好玩】从黑客帝国开始

 4 years ago
source link: http://www.cnblogs.com/tangshiwei/p/11846710.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

前言

笔者之前有一段时间一直在学习Canvas相关的技术知识点,通过参考网上的一些资料文章,学着利用简单的数学和物理知识点实现了一些比较有趣的动画效果,最近刚好翻看到以前的代码,所以这次将这些代码实践重新梳理一遍后整理成文,自己巩固复习的同时,可以和大家一起交流学习。作为【 Canvas真好玩 】系列的第一篇文章,笔者还是从最经典的黑客帝国开始,在一步一步进行代码具体实践的同时,带领大家进入神奇的Canvas动画的世界。

代码已上传至 Github ,可以拉下来后直接运行,省掉下面的准备工作环节。

效果图

NFVJjqe.gif

准备工作

因为之前的代码比较久远,这次打算使用 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;
}

至此,我们项目的基本代码结构就已经书写完毕,这里先贴一张我目前已经完成的页面效果:

iEfeyia.png!web

其实也没有那么好看,主要是为了方便管理菜单,接下来我们就来一步一步分析实现页面中炫酷的黑客帝国效果吧。

实现

在代码实践之前,我们先来分析一下黑客帝国的实现细节,在上面的动画效果中,我们可以知道,动画其实就是 由各种英文字母,数字以及特殊符号实现的一个从上到下的距离偏移效果 ,所以我们在代码中会维护一个集合用于存放所有可能出现的文字。其次,我们可以看出,文字的下坠效果其实是分成了多列的,当然列数会根据Canvas容器的宽度来动态计算。为了实现动画,我们这里可以借助浏览器的 requestAnimationFrame 来保持每秒60帧的流畅度,相信大部分前端人员对这个Api已经不陌生了,不过这里需要注意以下两点:

  1. 若想在浏览器下次重绘之前继续更新下一帧动画,那么回调函数自身必须再次调用requestAnimationFrame()
  2. 为了提高性能和电池寿命,因此在大多数浏览器里,当requestAnimationFrame() 运行在后台标签页或者隐藏的iframe里时,requestAnimationFrame() 会被暂停调用以提升性能和电池寿命

通过这个动画Api我们就可以在每帧的时间内清空当前的Canvas容器状态,同时计算每个文字的新坐标并进行绘制,我们可以为每列文字的Y轴偏移定义一个初始变量为1,即表示一个字体单位的大小,每次当文字下落一个字体大小的时候,将这个初始变量加1,这样在下次计算文字坐标的时候,就可以将这个值乘以字体大小从而得出Y轴的坐标,这样在视觉上就达到了一个文字的下坠效果。这里需要提一下的是, Canvas的坐标系统和理科领域的笛卡尔坐标系有点不太一样,采用默认的窗口坐标系统,即原点坐标位于窗口的左上角,沿X轴方向向右为正值,沿Y轴方向向下为正值 ,在后续计算文字坐标的时候需要注意这里的区别,其实窗口坐标系统中也是有负值的,只是跑到了屏幕之外,我们一般没有注意到而已。

笛卡尔坐标系:

FNzaIfR.png!web

窗口坐标系:

IfmqAzE.png!web

关于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'}}/>
    );
}

添加以上代码之后,我们来看看目前的效果:

Ajmuquy.gif 这个效果并不是我们理想中的样子,我们分析一下问题出现的原因,在以上代码实现中, draw 函数用于绘制文字,如果检测到文字当前已经超出容器范围,则会重置 dropUnits 数组中的值为0,那么导致的后果就是, dropUnits 数组中的每一项都为0,所以每列文字的Y轴起始坐标始终都是相同的,也就造成上面的效果。所以我们只需要想办法让Y轴的起始坐标错开,那么也就达到了预期的效果了,当然这种错开也是随机的,所以就很容易想到使用 Math.random 方法增加随机数判断来实现了,我们对以上代码稍作一下修改:

- if (y > h) {
+ if (y > h && Math.random() > 0.98) { // 此处增加随机数判断,只有满足条件后才进行重置
    dropUnits[i] = 0;
}

我简单画了张图来帮助理解一下这个过程,图中两个方块代表两个文字,布尔值代表上面代码中if条件的结果:

EfeyYf6.gif

上图中可以清楚地看到新增了随机数之后,文字的Y轴坐标产生了差异,修改后的效果如下:

MrA3muQ.gif

离预期的效果越来越近了,但是这个效果看起来有点生硬,因为我们在每一帧中绘制文字之前,会使用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!

你的一个点赞,值得让我付出更多的努力!

逆境中成长,只有不断地学习,才能成为更好的自己,与君共勉!

IbaQFj7.png!web


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK