R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。

[完结撒花] 飞机墙的历史 & 揭秘飞机墙的实现方法

注意:本文发布于 2784 天前,文章中的一些内容可能已经过时。

本文禁止发布在除 纸飞机技术博客、微信号 nuaazfj 和 /^1?$|^(11+?)\1+$/ | Where there is a will there is a way. 之外的平台,但不限制在社交网站上的转载。

纸飞机各种晚会上的飞机墙是不是已经非常吸引大家的眼球了?我从大一接锅开始写,到后来的负责人,然后现在把墙传给了后人。今天就来讲一下飞机墙的历史,然后会花上一些篇幅来解析一下飞机墙在代码上的实现方法。

飞机墙,顾名思义就是纸飞机自己的墙,跟人人墙微信墙作用差不多的墙,在晚会上大家通过某些方式发出自己的吐槽,然后屏幕上会即时显示。飞机墙的第一次现身是在纸飞机五周年生日会上,当时的图是这样的:

0

留言的方式是论坛回帖,信息会从空白区域的上面下来,据说无法点开看(当时的代码早没了所以我也没法再见到了)。

想想那个时候,小忆是部长,三三是干事,我还没入学……那个时候的飞机墙是一个初版,不是很稳定,没太多功能,挂了若干次,但是在生日会上还是成功激起了大家的纸飞机情怀,这个要赞一个!

后来就到了第六届蛋蛋晚会(主题:匿名登录),在这之前一个多月的时候我跑去办公室玩,于是三三问我要不要稍微改一下飞机墙,于是我就开始了接锅生涯。当时的技术部培训才刚讲完 HTML 和 CSS,JavaScript 才刚开了个头。为了这飞机墙,我在一个周末的时间里自学了 jQuery、PHP、MySQL 以及 Discuz! 的插件开发,然后居然就这么改完了。后来又陆陆续续小改了几下,修了几个 BUG,三三又根据当时美工给的图改了几下,于是就有了这个样子:

1

这一版飞机墙的上墙方式还是在论坛上回帖,不同的是信息会从左边出来。有人喜欢也有人感觉不适应。此外,这一版飞机墙可以把每一个消息点开看了。至于稳定性则没有什么改进,当晚挂了若干次,每一次都是网页无响应(具体原因下面分析)。

然后是六周年生日会,飞机墙的样式表其实没有大改,然后我已经意识到了稳定性的问题,于是把代码改了一下,当晚飞机墙撑过了整个生日会。此外还添加了抽奖功能,将自己的生日添加在最前面,就可以在抽奖环节被抽到。

2014 毕业季(主题:音为梦响)的飞机墙技术上没有什么变化,只是背景换成了小黑板,有一种很温馨的感觉。

以上是旧版飞机墙的历史。下面来说一说现在用的飞机墙。

首先先说一件纸飞机的大事:

2014 年 6 月,纸飞机微信公众号(飞机耳朵)正式上线。

飞机耳朵是纸飞机的第一代微信公众号(现在的公众号叫“南航纸飞机”),是由我全权负责的,里面的内容包括纸飞机的一些功能、服务部的预约维修以及“关于我们”这几个功能。

想想当时设想的功能(飞机墙的版本控制、聊天机器人、客服系统)现在基本都实现了,我再感慨一下。

于是在第七届蛋蛋晚会(主题:第七感)开始前的一个月(没错又是一个月),我打算写一个新的飞机墙。这次是通过微信回复的方式上墙,前端也是完全重写了一遍,一开始在数据获取方面还是参考了旧版飞机墙,所以在网络不好的情况下可能仍然会有问题。后来经过各种脑洞大开的优化之后好了很多,并且形成了“数据获取/显示”互相分离的结构。

背景使用的是一个 3D 的旋转星空,一共六个方格,用了卡片效果(当时的图片其实是另一个星空,这个星空是在 2015 新生聚会的时候修改的):

2

这个飞机墙一直经过了七周年生日会、毕业季、2015 新生聚会。在这之前我去看了社巡的闭幕式,感觉抽奖方式好 low 啊,回想起之前在 SegmentFault D-Day 上看到的高大上的抽奖方式,于是此时身为技术部管委的我接着给部长发了通知:我就要写出那种效果,你们尽快。

于是就出现了一个很炫的抽奖效果:所有人的头像一开始是随机排列的,后来依次排成三维的球、螺旋、画廊,然后抽到的人头像居中放大显示,没抽到的就淡出消失。由于当时的数据与现在的代码已经不兼容,所以过会儿用现在的代码来看效果。

到了第八届蛋蛋晚会(主题:次元崩塌),我又突发奇想把背景改了一下,这次是模仿 OSU! 的背景(虽然不太像)以及特效(喷星星、通道错位、屏闪),看起来效果还不错。不过抽奖没有用上。然后飞机墙就稳定下来了,八周年生日会飞机墙没有变动。下图是抽奖的界面:

3

到了 2016 新生聚会,看着新生做的 PPT,感觉风格很好啊,于是就想把飞机墙也改成 PPT 的风格,于是就有了这样一版飞机墙,背景的特效是 3D 的旋转星空、三个用线连起来的目标、中间一条反映现场音量的波形:

5

下面开始说技术。

飞机墙能即时显示消息,离不开 AJAX 这个技术。AJAX 使用 XmlHttpRequest 来异步请求一段数据,获取到数据之后会触发 onreadystatechange 监听器,里面就是请求到的数据,这样就可以使用 JavaScript 来更新页面的内容了。把 AJAX 请求函数包在 setInterval 里就可以实现定时轮询的效果。其实可以考虑一下 WebSocket,就看后人能不能实现了。WebSocket 的思路后文会提到。

旧版飞机墙是论坛的一个插件,每次 AJAX 的请求都是一个 .inc.php 文件,这个文件里调用了一个 Discuz! 的模板,也就是说返回的结果是一段 HTML。数据查询的方式很简单,在插件后台设置了帖子 ID 之后,直接数据库里一个 SELECT 语句就可以了。当时的一个后端 BUG 是可以获取任意帖子的数据。

本来以为年代久远代码已经找不到了,没想到前一阵翻自己的 GitHub 的时候偶然发现了这么个项目。贴一下后端相关的代码:

if(!defined("IN_DISCUZ")) exit("Access denied!");
require_once libfile('function/discuzcode');
// 帖子 ID
$tid = isset($_GET['tid']) ? $_GET['tid'] : $WallID;
// 最大返回条数
$maxReturn = isset($_GET['max']) ? $_GET['maxReturn'] : 50;
// 最后一次返回的 ID
$lastpos = isset($_GET['lastpos']) ? $_GET['lastpos'] : 0;
// 获取数据
$sql = "SELECT dateline, authorid, position, message, author FROM " . DB::table("forum_post") . " WHERE tid = '$tid' AND position > $lastpos ORDER BY position";
$result = DB::fetch_all($sql);
if (empty($result)) exit("empty");
// 做一些修正
while (count($result,0) >= $maxReturn && $lastpos == 0) array_shift($result);
// 帖子的表情
$sql = "select * from " . DB::table("common_smiley") . " where type = 'smiley'";
$smile = DB::fetch_all($sql);
$sql = "select * from " . DB::table("forum_imagetype");
$imgtype = DB::fetch_all($sql);
foreach ($result as $k2 => $r) {
    $r['message'] = discuzcode($r['message'], 0, 0, 1);
    $resultAft[$k2] = $r;
}
// 渲染最终的 HTML
include template("PlaneWall:ajax");
exit();

模板的代码是这样的:

{loop $resultAft $t}
    <ul class="Wall_ul" pos="{$t['position']}">
        <li class="Wall_userinfo" >
            <span class="Wall_avatar">
                {avatar($t['authorid'],middle)}
            </span>
            <span class="Wall_uname">
                <b>{$t['author']}</b>
                <div class="Wall_uname_bg"></div>
            </span>
        </li>
        <li class="Wall_content">
            <span class="Wall_cont">{$t['message']}</span>
        </li>
        <span class="Wall_floor">{$t['position']}楼</span>
    </ul>
{/loop}

所以最终返回的就是一个包含了许多数据的 HTML 片段。再往后后端的代码基本没变,主要还是前端。

页面分为三块:pre、stage、after,其中只有 stage 是屏幕上可见的(里面固定只有三个元素)。每次 AJAX 之后将获取到的 HTML 片段直接追加到 pre 的最前方,然后将 pre 后方的元素一个个删掉(片段里有几条,pre 就删几条),并将其 append 到 stage 中;stage 多出来的元素删掉,append 到 after 中。每次点击“上一个”时,把 after 的第一个搞到 stage 中,stage 的第一个搞到 pre 中;“下一个”时刚好相反,大家可以自行脑补。

4

下面是 AJAX 相关的代码:

jQuery.ajaxSetup({
    url: "./plugin.php?id=PlaneWall:PlaneWall&action=ajax",
    type: "POST",
    timeout: ajaxTimeOut,
    data: { WallID:WallID, lastpos:lastpos },
    // 避免重复发送请求
    beforeSend: function(){
        state.ajax = "lock";
    },
    complete: function(){
        state.ajax = "free";
    },
    // 过滤掉帖子中的换行和附件
    dataFilter : function(data){
        data = data.replace(/(<br \/>|<br>|<br >|\n)/g,"");
        data = data.replace(/(\[attach\].*\[\/attach\])/g,"");
        return data;
    },
    // 成功之后将 data 插入到 pre 中
    success : function(data){
        if (data != "empty"){
            jQuery(".Wall_pre").append(data);
            // 根据内容长度来调整字体大小的函数
            jQuery(".Wall_pre").children(".Wall_ul").each(function () {
                DOMDeal(this);
            });
            var DOM = jQuery(".Wall_pre").children();
            jQuery(".Wall_screw").append(DOM);
            // 计算当前最后一条数据的 ID
            jQuery(".Wall_main").find(".Wall_ul").each(function () {
                if (Number(jQuery(this).attr("pos")) > lastpos)
                    lastpos = Number(jQuery(this).attr("pos"));
            });
            jQuery.ajaxSetup({ data: { lastpos: lastpos } });
        }
    }
});

由于使用了 HTML DOM 作为保存信息的方法,所以飞机墙的稳定性一直是问题。实测超过 500 条就会因为内存耗尽而崩溃掉。后来的方法是:只保留最近的 50 条,但是这样如果想加抽奖就不好加了,对前面的人不公平。

至于前端还是挺简单的,只需要计算好每条记录的坐标,然后用 jQuery 来定位和写动画就好了。点开一条消息的效果也很简单,复制一份相同的 DOM 节点,计算全屏时的坐标,然后写一下动画就好了(毕竟当时我还没有完全理解 CSS,不知道可以用纯 CSS 来实现)。

下面说一说现在用的飞机墙。

新版飞机墙的后端与纸飞机的电子办公系统是打通的。每一个版本的飞机墙都会在数据库中有记录(表名:pw_list)。当开启飞机墙的时候,通过微信向公众号“南航纸飞机”发送的消息,会通过专门的入口转发到专门的数据表(表名:pw_post)中,里面有一个字段标明了这条消息属于哪一个飞机墙。这样的话取数据就相当好取了。此外还有通过微信的 MsgId 判重的功能,避免网络不好的情况下一个人重复发消息霸屏的现象发生。

下面是现在的数据库结构:

CREATE TABLE IF NOT EXISTS `pw_list` (
  `pw_id` int(11) NOT NULL AUTO_INCREMENT,
  `pw_title` varchar(50) NOT NULL COMMENT '飞机墙主题',
  `pw_maxFloor` int(11) NOT NULL COMMENT '总楼层数',
  PRIMARY KEY (`pw_id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COMMENT='飞机墙版本列表';
CREATE TABLE IF NOT EXISTS `pw_post` (
  `pw_post_id` int(11) NOT NULL AUTO_INCREMENT,
  `pw_unique_id` int(11) NOT NULL COMMENT '当前的楼层数',
  `pw_id` int(11) NOT NULL COMMENT '所属的飞机墙 ID',
  `pw_content` text CHARACTER SET utf8mb4 NOT NULL,
  `pw_author` varchar(50) CHARACTER SET utf8mb4 NOT NULL,
  `pw_avatar_url` text,
  `pw_msgid` varchar(100) NOT NULL COMMENT '去重用',
  PRIMARY KEY (`pw_post_id`),
  UNIQUE KEY `pw_msgid` (`pw_msgid`),
  KEY `pw_id` (`pw_id`),
  KEY `pw_author` (`pw_author`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COMMENT='飞机墙内容列表';

后端获取数据的代码也是超级简单:

// 飞机墙版本
$pw_id = (intval($content['ver']) > 0) ? intval($content['ver']) : get_setting('current_pw_id');
// 是否显示楼层数
$id = !(isset($content['id']) && $content['id'] == 0);
// 是否显示头像
$avatar = !(isset($content['avatar']) && $content['avatar'] == 0);
// 是否显示作者
$user = !(isset($content['user']) && $content['user'] == 0);
$return['maxPos'] = DB::getOne("SELECT `pw_maxFloor` FROM `pw_list` WHERE `pw_id` = {$pw_id}", 'pw_maxFloor');
// 如果有内容
if (isset($content['lastpos']) && $content['lastpos'] < $return['maxPos']) {
    $content['lastpos'] = intval($content['lastpos']);
    if ($content['lastpos'] < -1) {
        $sql = "SELECT * FROM `pw_post` WHERE `pw_id` = $pw_id AND `pw_unique_id` > {$return['maxPos']} + ({$content['lastpos']})";
    }
    else if ($content['lastpos'] == -1) {
        // 查找全部数据
        $sql = "SELECT * FROM `pw_post` WHERE `pw_id` = $pw_id";
    }
    else {
        // 获取大于lastpos的数据
        $sql = "SELECT * FROM `pw_post` WHERE `pw_id` = $pw_id AND `pw_unique_id` > {$content['lastpos']}";
    }
    $result = DB::getAll($sql);
    $return['d'] = [];
    $maxpos = -1;
    foreach ($result as $value) {
        if ($value['pw_unique_id'] > $maxpos) $maxpos = $value['pw_unique_id'];
        $t = ['cont' => $value['pw_content']];
        if ($id) $t['id'] = $value['pw_unique_id'];
        if ($user) $t['user'] = $value['pw_author'];
        if ($avatar) $t['avatar'] = $value['pw_avatar_url'];
        $return['d'] []= $t;
    }
    $return['maxPos'] = $maxpos;
}
// 若没有新数据,则直接返回空串
else {
    $return = [];
}

新版飞机墙的每一个版本后端都是一致的,前端也大体相同,屏幕上有六个方格,而且始终只有这六个方格。每次更新直接一个闪光过去更新掉里面的 innerHTML,不会因为内存耗尽而崩溃。

新版飞机墙的数据格式是 JSON,也只保留了最基本的信息(用户、内容、微信号),所以与旧版比起来相当轻量。一开始也是每次 AJAX 之后更新页面的,但是发现网络不好的时候还是有问题,于是后来将数据获取的流程独立了出来,即:一个函数每隔三秒 AJAX 一次,并将结果存在一个变量中;另一个函数每隔一段指定的时间就将之前变量中尚未显示在屏幕上的数据取一条显示在屏幕上。具体来讲,每次 AJAX 的时候往后台传一个 lastFloor,后台返回第 lastFloor 条之后的全部数据;前端将里面的 QQ 表情和 Emoji 用正则表达式转义为图片,然后存在一个数组里等待另一个函数更新(JSON 数组占用不了太大空间的),同时计算出独立的用户存在另一个 Hash 数组里,以备抽奖之用。所以重复上墙是不能增加被抽中的概率的;更新流程则是每隔一段时间就看一下 Messages 数组是不是有了新的数据,有的话就更新一条,并把当前指针(其实就是个数字)指向这条数据的位置。指针的好处就是当你使用上一页和下一页的时候写起来相当方便,直接 -6 或者 +6 之后修正一下溢出就好了。

其实如果使用 WebSocket 的话,消息的获取会更及时,资源消耗也会少很多。大体的思路是:微信服务器收到消息之后 curl 请求一下指定的一个 API,这个 API 用 Node.js 来写,收到消息的时候先存在数据库中,然后向所有已连接的客户端发送消息。只需要随便用一个 Socket.io 的库就能完成这个效果了。

那六个方格点击放大的效果用的是 CSS3 的过渡(transition)和3D动画(perspective 和 transform:rotateY)以及一些 top、left 之类的属性,所以只需要在点击的时候改一下 className 就可以了。

.pw-tile {
    transition: all 1s;
    position: absolute;
    display: inline-block;
    width: 300px;
    height: 277.5px;
    opacity: 1;
    z-index: 0;
}
.pw-msg {
    display: inline-block;
    width: 100%;
    height: 81%;
    box-sizing: border-box;
    padding: 15px;
    padding-left: 20px;
    overflow: hidden;
    transition: all 500ms;
}
.pw-msg img {
    width: 43px;
    height: 43px;
    border-radius: 50%;
    background: #FFF;
    transition: all 500ms;
}
.pw-author {
    display: inline;
    line-height: 52px;
    margin-right: 10px;
    vertical-align: middle;
    transition: all 1s;
}
.pw-avatar {
    position: absolute;
    display: inline-block;
    width: 100%;
    height: 52px;
    left: 0;
    bottom: 0;
    text-align: right;
    font-size: 24px;
    vertical-align: middle;
    background-color: rgba(0,0,0,0.2);
    transition: all 1s;
}
.pw-avatar > img {
    width: 40px;
    height: 40px;
    vertical-align: middle;
    border-radius: 50%;
    border: 2px solid #FFF;
    transition: all 1s;
}
/* 这个 class 是加在 .pw-tile 上的 */
.fullscr {
    opacity: 1 !important;
    top: 20px !important;
    left: 20px !important;
    padding: 0;
    width: 920px !important;
    height: 560px !important;
    z-index: 100;
    font-size: 60px;
    transition: all 1s;
    transform: rotateY(360deg);
}
.fullscr .pw-msg {
    height: 85.8%;
    font-size: 90px;
    overflow-y: auto;
    padding: 40px;
    transition: all 1s;
}
.fullscr .pw-msg img {
    width: 90px;
    height: 90px;
}
.fullscr .pw-author {
    font-size: 36px;
    line-height: 80px;
    margin-right: 20px;
    transition: all 1s;
}
.fullscr .pw-avatar {
    height: 80px;
    transition: all 1s;
}
.fullscr .pw-avatar img {
    width: 60px;
    height: 60px;
    border: 4px solid #FFF;
    transition: all 1s;
}

抽奖是某只达达写的,使用了 Three.js,说简单也简单,说难也难,流程大概是这样的:

初始化的时候,计算出每个目标在随机、球形、螺旋、画廊、抽奖五个状态时的三个坐标与三个旋转角度,其中抽奖状态的计算比较特殊,有一个随机到的幸运儿会被直接指定为屏幕中央,其它的全都是随机并且透明度为 0;显示的时候默认是随机状态,然后使用 setTimeout 来控制状态的变化,最后变为抽奖状态;点击重置按钮会直接将其置为随机状态并重新计算抽奖状态(不能每次都抽一样的人吧?)。

随机状态和抽奖状态的计算都超级简单(反正都是随机数),下面给出球形、螺旋、画廊状态的计算代码,其中 players 是所有人头像的集合:

// 球形
var vector = new THREE.Vector3();
for (var i = 0, l = players.length; i < l; i++) {
    var phi = Math.acos(-1 + (2 * i) / l);
    var theta = Math.sqrt(l * Math.PI) * phi;
    var object = new THREE.Object3D();
    object.position.x = 800 * Math.cos(theta) * Math.sin(phi);
    object.position.y = 800 * Math.sin(theta) * Math.sin(phi);
    object.position.z = 800 * Math.cos(phi);
    vector.copy(object.position).multiplyScalar(2);
    object.lookAt(vector);
    targets.sphere.push(object);
}
// 螺旋
var vector = new THREE.Vector3();
for (var i = 0, l = players.length; i < l; i++) {
    var phi = i * 0.175 + Math.PI;
    var object = new THREE.Object3D();
    object.position.x = 900 * Math.sin(phi);
    object.position.y = i * 8 - players.length * 4;
    object.position.z = 900 * Math.cos(phi);
    vector.x = object.position.x * 2;
    vector.y = object.position.y;
    vector.z = object.position.z * 2;
    object.lookAt(vector);
    targets.helix.push(object);
}
// 画廊
for (var i = 0; i < players.length; i++) {
    var planeNum = players.length / 25;
    var object = new THREE.Object3D();
    object.position.x = ((i % 5) * 400) - 800;
    object.position.y = (-(parseInt(i / 5) % 5) * 400) + 800;
    object.position.z = parseInt(i / 25) * 200 - 200 * parseInt(planeNum / 2);
    targets.grid.push(object);
}

至于如何用 Three.js 来控制物件,下面是样例代码:

// target 就是这个物件的坐标和旋转数据
new TWEEN.Tween(object.position)
    .to({
        x: target.position.x,
        y: target.position.y,
        z: target.position.z
    }, Math.random() * duration + duration)
    .easing(TWEEN.Easing.Exponential.InOut)
    .start();
new TWEEN.Tween(object.rotation)
    .to({
        x: target.rotation.x,
        y: target.rotation.y,
        z: target.rotation.z
    }, Math.random() * duration + duration)
    .easing(TWEEN.Easing.Exponential.InOut)
    .start();

背景的动画效果才是新版飞机墙的亮点。下面就依次讲解一下吧!

旋转星空一开始用的是 jQuery,后来发现 CSS3 也可以实现,所以果断改用 CSS3 的 animate 了:

@keyframes bganimate{
    0% { transform: rotateX(25deg) rotateZ(0deg); }
    100% { transform: rotateX(25deg) rotateZ(-360deg); }
}
#pw-bg-animate{
    background: url(bg1.jpg) center;
    width: 1920px;
    height: 1920px;
    top: -720px;
    left: -640px;
    background-size: contain;
    animation: bganimate 180s infinite linear;
}

反正只要能找到有一个旋转中心的星空图就可以了。必应搜一下“旋转星空”可以搜到好多,这也属于偷工减料的一种体现吧,23333。

OSU! 的特效则使用了 Canvas,涉及到 Canvas API 和参数方程等内容,稍微复杂一些。首先先看看更新的函数:

function update() {
    // 只有页面可见时才更新
    if (!(document.hidden)) {
        // 绘制背景
        fillBackground();
        // 绘制上升的三角形
        for (var i in triangles) updateTriangle(i);
        // 绘制星星(如果有的话)
        var hasActiveStar = false;
        for (var i in stars) if (updateStar(i)) hasActiveStar = true;
        if (!hasActiveStar) stars = [];
        // 通道错位特效(如果有的话)
        if (channelTime <= 1) updateChannel();
        // 叠在上面的一个阴影图层
        fillFinalShadow();
        // 最后的闪光效果(如果有的话)
        if (flashTime >= 0) updateFlash();
    }
    requestAnimationFrame(update);
}

那么就说说这几个函数吧,绘制背景很简单,用一段渐变就好了:

var gradient = self.ctx.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, "#2070DE");
gradient.addColorStop(1, "#B07F40");
self.ctx.fillStyle = gradient;
self.ctx.fillRect(0, 0, width, height);

三角形的坐标变化很简单,就是每次减掉一定的数字即可。绘制三角形也只需要这样:

// 通道错位
var delta = channel * i;
// 描绘路径
self.ctx.beginPath();
self.ctx.moveTo(tri.x + tri.size / 2 + delta, tri.y);
self.ctx.lineTo(tri.x + delta, tri.y + tri.size / 2 * Math.sqrt(3));
self.ctx.lineTo(tri.x + tri.size + delta, tri.y + tri.size / 2 * Math.sqrt(3));
self.ctx.closePath();
// 设置填充格式
var style = [0, 0, 0, tri.color.alpha];
style[i + 1] = tri.color.rgb;
self.ctx.fillStyle = "rgba(" + style.join(",") + ")";
// 类似于 PS 中的叠加
self.ctx.globalCompositeOperation = "lighter";
self.ctx.fill();
// 类似于 PS 中的覆盖
self.ctx.globalCompositeOperation = "source-over";

星星的绘制也比较简单,不过这里涉及到了一点小知识,星星是有旋转的,但是 Canvas 不能直接在绘图时绘制旋转的图片,需要先将 context 进行旋转,然后绘制图片(Canvas 绘图的思想以及如何操作 context 我可能以后还会发文章写一写)。星星的路径比较好玩,只需要在一开始设置好每个星星的初速度和角度,然后每一次更新的时候速度都会减少(取决于这个星星还有多少存活时间)。

通道错位无非就是将一个白色的三角形分成了红绿蓝三个三角形使用叠加效果分别绘制。阴影和闪光也就是设置了填充的渐变之后做了一个径向填充。

2016 新生聚会的飞机墙背景则是三个特效。首先先一个纯色填充画出基准色,旋转星空则是用了这种效果:

6

其中星空是绕着竖着的轴旋转的。至于实现的方式,则是控制一个 angle 变量,每次更新的时候都增加一点,然后通过各种直角坐标和大小的计算,最终通过 fillRect 绘制出来;三个目标是两个圆和两个弧线,弧线的大小是固定的,角度也是每次更新时增加一点,增加的速率与半径有关;至于波形就要好好讲一讲了。

首先使用 getUserMedia 函数来获取麦克风的访问权限,获取到之后将数据通过 ScriptProcessor,这样可以将音频数据直接更新至一个数组中,方便在画面更新时调用(这个数组不要求每秒更新 60 次,因为声波没有必要如此频繁):

navigator.getUserMedia = (navigator.getUserMedia || navigator.mozGetUserMedia || navigator.webkitGetUserMedia || navigator.msGetUserMedia);
navigator.getUserMedia({ audio: true }, function (stream) {
    var context = new AudioContext;
    var source = context.createMediaStreamSource(stream);
    analyser = context.createScriptProcessor(1024, 1, 1);
    analyser.onaudioprocess = function (e) {
        audioBuffer = e.inputBuffer.getChannelData(0);
    };
    source.connect(analyser);
    analyser.connect(context.destination);
}, function () {});

然后在画面更新的函数中将其绘制在屏幕上。为了减少绘制量,我只用了前 256 个数据点(根据前文 ScriptProcessor 的设置,总共 1024 个数据点):

if (analyser) {
    ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
    ctx.beginPath();
    for (var i = 1; i < 256; i++) {
        ctx.lineTo(width * i / 256, centerHeight + (height >> 2) * audioBuffer[audioBuffer.length * i / width | 0]);
    }
    ctx.stroke();
}

不过 getUserMedia 有个坑,就是 Chrome 只允许 localhost 和 HTTPS 站点使用这个函数,而纸飞机服务器是没有 HTTPS 的,因此我又将其放在了七牛云存储上,通过七牛提供的 HTTPS 地址来访问。


以上就是全部飞机墙的历史和技术细节。不知道以后技术部的同学们会将飞机墙写成什么样子呢?期待新的创意~

Disqus 加载中……如未能加载,请将 disqus.com 和 disquscdn.com 加入白名单。

这是我们共同度过的

第 3072 天