云开发·云调用生成小程序码

小程序云开发已经支持云调用,开放了很多接口,一直想要的获取小程序码也支持了。这下轻量的小程序也可以有自定义小程序码的功能。

1. 需求

获得一个带参数的小程序码,传播出去以后,用户扫码进入指定页面,根据参数做不同的处理。本文只讲小程序码生成、存储、展示部分。参数处理不多介绍,可以查看 项目代码 了解更多。

2. 开通云开发

新建小程序可以从开发工具的云开发模板初始化项目,根据云开发操作指引新建项目即可。

但是这里有个问题,已发布小程序的页面才能生成小程序码。如果现有的小程序没有开通云开发,需要做以下几步:

  1. 开发工具开通云开发,设定云开发的环境;
  2. 将原来的代码(除了project.config.json以外的所有文件)放到新建的 miniprogram 目录;
  3. 新增 cloudfunctions 目录;
  4. app.json 新增配置 "cloud": true
  5. project.config.json 配置 "miniprogramRoot":"miniprogram/""cloudfunctionRoot":"cloudfunctions/"
  6. 修改小程序基础库版本,最低要 2.3.0 "libVersion": "2.3.0"

3. 生成小程序码

下面可以开始写代码开发了,开始之前,建议先看完官方教程。特别是开发工具的使用步骤,开发和调试时如果遇到奇怪的问题,可以尝试重启开发工具、重装开发工具,也可以去微信开放社区发帖。(重启和重装都是我在社区中发现的答案,能解决各种不应该存在的问题)。

3.1 准备文件

cloudfunctions目录右键新建Node.js云函数 getqr

生成小程序码需要单独指定权限。在 getqr 目录新建 config.json ,里面写以下内容:

{
  "permissions": {
    "openapi": [
      "wxacode.getUnlimited"
    ]
  }
}

小程序码的获取方式有三种,这里只用到了接口 getUnlimited,选择这个接口的原因是漂亮的圆形小程序码,数量无限制。具体区别可以去 获取小程序码官方文档查看详情。

正常情况下,这个时候云函数可以部署测试了。如果遇到部署不成功、各种权限问题,可以尝试本地部署上传所有文件、重启试试。

3.2 生成小程序码

生成小程序码的代码如下,可以指定页面和页面参数 scene,还有小程序码的尺寸。

注意这里的 scene 有限制:
1. 最大32个可见字符;
2. 只支持数字,大小写英文以及部分特殊字符:!#$&'()*+,/:;=?@-._~
3. 注意参数格式:下面实例代码生成小程序码后,扫码获得 pages/demo/demo?scene=id%3D6

try {
  const result = await cloud.openapi.wxacode.getUnlimited({
    page: 'pages/demo/demo',
    scene: 'id=6',
    width: 240,
  })
  console.log(result)
  return result
} catch (err) {
  console.log(err)
  return err
}

直接调用,比服务端调用少了 access_token 参数。

3.3 上传到云存储

返回值中的 buffer 就是图片内容,直接上传到云存储:

const uploadResult = await cloud.uploadFile({
  cloudPath: 'shareqr/' + qr_name_hash + '.jpg',
  fileContent: result.buffer,
});
  • 我在云存储新建了 shareqr 目录保存小程序码;
  • 图片名根据参数取md5摘要;
  • getUnlimited 返回的图像是 jpeg 格式,后缀硬编码写 .jpg

3.4 获取图片临时路径

直接上代码

getURLReault = await cloud.getTempFileURL({
  fileList: [uploadResult.fileID]
});
fileObj = getURLReault.fileList[0]
return fileObj

3.5 直接从存云存储获取

生成过以后图片已经保存在云存储,用同样的参数第二次调用没必要再生成一次,去掉一次网络请求,可以节省不少时间。

前面说到文件名使用请求参数摘要,知道了目录和文件名,再加上文件bucket前缀就可以拼出来 fileID,用fileID 可以查询云存储的文件。

比如我刚刚生成的 fileID 是 cloud://dev-xxxx.8888-dev-xxxx/qr/44ea42f05091c3bec771123e6e8cd4c2.jpg, 前缀就是 cloud://dev-xxxx.8888-dev-xxxx/。再拼上目录、文件名、后缀就是 fileID

注:此处的 fileID拼接方法并不是来自官方文档,只是在使用中发现这个前缀不会变。还需要官方解释说明fileID规则。
如果会改变,就需要再用云数据库存储fileID,更麻烦一些。

3.6 云函数完整代码

// 云函数入口文件
const cloud = require('wx-server-sdk');
const crypto = require('crypto');
const bucketPrefix = 'cloud://dev-xxxx.8888-idc-4d11a4-1257831628/qr/'; // env: 'dev-xxxx'

// 云函数入口函数
exports.main = async (event, context) => {
  const full_path = event.page + '?' + event.scene;
  const qr_name_hash = crypto.createHash('md5').update(full_path).digest('hex');
  const temp_id = bucketPrefix + qr_name_hash + '.jpg';
  // return {
  //   full_path,
  //   qr_name_hash,
  //   temp_id
  // }

  try {
    // 先尝试获取文件,存在就直接返回临时路径
    let getURLReault = await cloud.getTempFileURL({
      fileList: [temp_id]
    });
    // return getURLReault;
    let fileObj = getURLReault.fileList[0];
    if (fileObj.tempFileURL != '') {
      fileObj.fromCache = true;
      return fileObj;
    }

    // 生成小程序码
    const wxacodeResult = await cloud.openapi.wxacode.getUnlimited({
      scene: event.scene,
      page: event.page,
      width: 240
    })
    // return wxacodeResult;
    if (wxacodeResult.errCode != 0) {
      // 生成二维码失败,返回错误信息
      return wxacodeResult;
    }

    // 上传到云存储
    const uploadResult = await cloud.uploadFile({
      cloudPath: 'qr/' + qr_name_hash + '.jpg',
      fileContent: wxacodeResult.buffer,
    });
    // return uploadResult;
    if (!uploadResult.fileID) {
      //上传失败,返回错误信息
      return uploadResult;
    }

    // 获取图片临时路径
    getURLReault = await cloud.getTempFileURL({
      fileList: [uploadResult.fileID]
    });
    fileObj = getURLReault.fileList[0];
    fileObj.fromCache = false;

    // 上传成功,获取文件临时url,返回临时路径的查询结果
    return fileObj;

  } catch (err) {
    return err
  }

}

4. 小程序页面调用

调用页面就比较简单了,在小程序新建一个 pages/share/shareonLoad 函数调用云函数。

// 使用前记得先初始化云函数,一版放到 app.js onLaunch() 中
// wx.cloud.init({env: 'dev-8888'})

wx.cloud.callFunction({
  name: 'getqr',
  data: {
    page: 'pages/demo/demo',
    scene: 'id=6',
  }
}).then(res => {
  console.log(res.result);
  if (res.result.status == 0) {
    _this.setData({
      qr_url: res.result.tempFileURL
    })
  }else{
    wx.showToast({
      icon: 'none',
      title: '调用失败',
    })
  }
}).catch(err => {
  console.error(err);
  wx.showToast({
    icon: 'none',
    title: '调用失败',
  })
})

至此完整的调用过程已经全部完成,详细代码可以到 项目代码 查看。

代码中还对入口页面和share页面的参数做了包装,云函数可以直接使用,小程序可以稍做修改适应自己业务。

写在最后

小程序云开发已经开放了很多功能,除了这次提到的生成小程序码,云调用还可以发送模板消息。有需要的开发者又一个理由可以快速上线新功能了。

云开发还开放了HTTP API,也就是用自己的服务器调用云函数。以前看完云开发介绍文章最大的疑问就是,你说的都很好,可是后台数据怎么管理呢?不能跟自己的服务器结合,只能放一些轻量的小程序。有了 HTTP API 以后就可以用自己的服务器做管理后台了。这时候你要问,都用上服务器了,还需要云开发做什么。首先,云开发免费;其次,免费功能已经够强,就差不能做Web管理后台了;最后,获取access_token(小程序及小游戏调用不要求IP地址在白名单内。)

模块化简史

JavaScript 中,模块化是一个现代的概念。本文将简略总结模块化在 JavaScript 世界中的演变路径。总之本文不是一个完整的列表,而是简要说明几个范式变化的里程碑。

1. Script标签和作用域

早期的JavaScript都是内联在HTML中的<script>标签中。好一些的会把内容放到单独的js文件中,总之所有的内容都全部在全局作用域 global scope

在任意一个文件中声明的变量都是在全局的 window 对象上,这样就会暴露到完全不想干的脚本里,可能会导致冲突甚至导致错误。可能脚本会无意中修改了另外一个脚本需要用到的变量。

随着Web应用程序的规模变大,也变得更复杂。作用域 scope的概念,和全局作用域 global scope 的危险逐渐被更多开发者理解。立即执行函数表达式(或IIFE)Immediately-invoking function expressions 被发明并很快成为当时的主流。这种方法把整个文件,或者文件中的一部分代码包含到一个立即执行的函数中。JavaScript中的每个函数都有自己的作用域,这样在函数内定义的变量不会隐式的变成全局变量,这不仅避免了外界访问此作用域的变量,又不会污染全局作用域。

以下是立即执行函数表达式的几个版本,每段代码的作用域都是独立的,要想传变量到全局作用域只能通过 wiindow.frrmeIIFE = true 这种写法:

(function() {
 console.log('IIFE using parenthesis')
})()

~function() {
 console.log('IIFE using a bitwise operator')
}()

void function() {
 console.log('IIFE using the void operator')
}()

使用立即执行函数表达式模式,代码库通过暴露一个唯一的组件并绑定到全局 window 对象上。这样就避免了全局命名空间污染。下面的代码片段展示了用这种模式创建的 mathlib 组件,它包含了一个sum方法。如果想为 mathlib 添加更多模块,可以把每个模块放到一个单独的立即执行函数表达式中,将新方法添加到 mathlib 公共接口,而模块中定义的其他变量都是私有的。

void function() {
 window.mathlib = window.mathlib || {}
 window.mathlib.sum = sum

 function sum(...values) {
   return values.reduce((a, b) => a + b, 0)
}
}()

mathlib.sum(1, 2, 3)
// <- 6

这种模式让开发者第一次可以安全的把每个立即执行函数表达式放到一个单独的文件中,让各种 JavaScript 开发工具激增,减轻了网络压力。

这种开发模式的缺点是没没有明确的依赖树,开发者必须精确的按顺序制作组件文件列表,在使用前先加载,而且要递归的加载。

2. RequireJS,AngularJS和依赖注入

在模块系统(比如RequireJS)还有AngularJS的依赖注入机制出现之前,基本上不考虑这个问题。

下面的代码演示了用 RequireJS 的 define 函数定义 mathlib/sum.js 代码库。这样就把函数添加到了全局作用域。define 回调的返回值,就能作为我们模块的公共接口。

define(function() {
 return sum

 function sum(...values) {
   return values.reduce((a, b) => a + b, 0)
}
})

我们可能会有个 mathlib.js 模块聚合了所有需要的功能。上面的例子中,只包含了 mathlib/sum, 但我们可以用同样的方式加载所有需要的依赖项。用数组方式传入依赖项,就会按照顺序得到他们的公共接口,作为参数传入回调中。

define(['mathlib/sum'], function(sum) {
 return { sum }
})

定义好一个库以后,就可以用 require 方法使用了。注意下面代码如何解析依赖关系链。

require(['mathlib'], function(mathlib) {
 mathlib.sum(1, 2, 3)
 // <- 6
})

这就是 RequireJS 和依赖关系树的优势。不管程序是否包含了成百上千的模块,RequireJS都会解析依赖关系树,不需要仔细维护列表。因为已经将依赖关系准确地列在里需要的位置,就不需要再维护长长的组件列表,以及他们的关系了。

复杂的依赖关系问题解决了,更大的好处是,在模块module级别,这种显示的依赖声明让组件component与应用程序其他部分的关系变得更明显,反过来促进了更大程度的模块化。

RequireJS 也不是没有问题,他的整个设计模式都围绕着异步加载模块的能力,部署后会在主要代码执行前瀑布式发出数百个网络请求。需要有个工具提前处理好,才适合部署到生产环境。问题又回来了,还是需要长长的依赖列表。

AngularJS 的依赖注入系统也遇到了相同的问题。但是他们用了一个优雅的解决方案,用字符串解析替代依赖数组,解析函数名来解决依赖关系。但这种机制与压缩工具不兼容,函数名编程单个字母后就不能正确解析了。

在 AngularJS v1 的后期,引入了一个构建任务,把下面的代码:

module.factory('calculator', function(mathlib) {
 // …
})

转换成下面的格式,这样就不怕压缩了,里面明确包含了依赖的列表:

module.factory('calculator', ['mathlib', function(mathlib) {
 // …
}])

毋庸置疑,这个鲜为人知的构建工具,让构建过程更长。再加上过度设计破坏了本不应该被修改的内容,得到的收益几乎可以忽略不计。大多数开发者还是选择去维护一份依赖数组。

3. Node.js和CommonJS的出现

Node.js又诸多创新,其中一个就是 CommonJS 模块系统。利用 Node.js 可以访问文件系统的优势,CommonJS标准更符合传统的模块加载机制。在 CommonJS 中,每个文件都是一个有自己作用域和上下文的模块。使用 require 函数同加载依赖项,就可以走模块的生命周期中随时动态调用。如下代码片段所示:

const mathlib = require('./mathlib')

与 RequireJS 和 AngularJS 类似,CommonJS 依赖关系也由路径名引用。主要区别是 boilerplate 函数和依赖列表都不需要了,并且模块的接口可以赋值给变量,也可以用在 JavaScript 表达式中。

与 RequireJS 和 AngularJS 不同的是 CommonJS 相当严格。RequireJS 和 AngularJS 可以有许多动态定义的模块,而 CommonJS 在文件与模块一一对应。同时,RequireJS 有几种声明模块的方法,AngularJS 有几种 factories、services、providers等,而且它的依赖注入机制与 AngularJS 框架本身紧密耦合。相比之下,并且只有一种声明模块的方式。一个 JavaScript 文件就是一个模块,调用 require 会加载依赖项,赋值给 module.exports 的任何东西都是它的接口。这样可以实现更好的工具,因为工具可以更容易理解 CommonJS 组件系统的层次结构。

最终 Browserify 的出现抹平了 Node.js 服务器和浏览器的 CommonJS 模块的差别。使用 browserify 命令行界面,只需要提供入口模块的路径,就能将难以想象的大量模块打包到一个文件中。终极功能 npm package registry,让 CommonJS 接管了模块加载生态系统。

当然 npm 不仅限于 CommonJS 模块,甚至不限于 JavaScript 包,但是这仍然是它的主要用途。只要敲几下代码,就能在Web应用中获得数千个包(现在总数已经超过50万个,仍在稳步增长中),并且能再 Node.js 服务端和浏览器中复用大部分功能。对其他系统来说这个优势太强大。

4. ES6,import,Babel和Webpack

随着 ES6 在2015年6月开始标准化,并且 Babel 在此之前很久就将 ES6 转换为 ES5,一场新的革命正在迅速逼近。 ES6规范包括 JavaScript 原生的模块系统,通常称为 ECMAScript 模块(ESM)。

ECMAScript 模块很大程度上受到 CommonJS 模块的影响,有静态声明API以及基于 Promise 的动态编程API,如下所示。

import mathlib from './mathlib'
import('./mathlib').then(mathlib => {
 // …
})

ECMAScript 模块中,每个文件都有自己的作用域和上下文。ECMAScript 模块优于 CommonJS 模块的一个主要优点就是它的静态引入依赖关系,可以让第三方工具在运行前得出模块之间的依赖关系,进行代码检查。

静态引入和有选择地引入,使得第三方工具可以有选择地删除未使用的包做优化,这个过程被称为 “tree shaking”

在 Node.js v8.5.0 中也支持了 ECMAScript 模块(需要设置flag)。 大多数现代浏览器也支持 ECMAScript 模块(需要设置flag)。

Webpack 是 Browserify 的继承者,由于具有更广泛的功能,基本上成了通用的模块 bundle 工具。 就像 Babel 和 ES6 一样,Webpack 长期以来一直支持 ECMAScript 模块的importexport 以及动态的 import()函数。 它引入了”code-splitting”机制,它能够将应用程序划分为不同的 bundle 包以提高首次加载体验的性能。

鉴于ECMAScript 模块是该语言的原生实现(对比CommonJS 模块)可以预期在几年内完全接管模块生态系统。

作者:Nicolás Bevacqua
原文:A Brief History of Modularity 发表于2017年9月26日
翻译:https://yukun.im/javascript/815

使用A-Frame从零开始做一个 Web 3D 互动游戏

不用学习新的语言,只有前端现有的知识就可以做AR了。而且是在我们熟悉的Web环境中,不用APP就可以做到。

1. 什么是A-Frame

这个框架的命名跟移动4G的“和”有得一拼,结果都是完美错过所有关键词。
A 会被浏览器忽略 – 是连词符,frame又有太多歧义。如果打出来 aframe 还会被浏览器“智能”拆分成 a frame 变成“一个框架”。
这就导致查资料不容易,沙里淘金的感觉,不过也可能是本身资料就少的缘故。

A-Frame 是一个可以在HTML中创建3d场景的框架,使用Three.js和WebGL来创建VR场景。
不需要了解低层渲染细节,但是A-Frame的说明文档也很长啊。

2. 最简单的demo

在codepen.io打开代码在codepen.io预览

<script src="https://aframe.io/releases/0.4.0/aframe.min.js"></script>

<a-scene>
  <a-sphere position="0 1.25 -5" radius="1.25" color="#EF2D5E"></a-sphere>
  <a-box position="-1 0.5 -3" rotation="0 45 0" width="1" height="1" depth="1" color="#4CC3D9"></a-box>
  <a-cylinder position="1 0.75 -3" radius="0.5" height="1.5" color="#FFC65D"></a-cylinder>
  <a-plane position="0 0 -4" rotation="-90 0 0" width="4" height="4" color="#7BC8A4"></a-plane>
  
  <a-sky color="#ECECEC"></a-sky>
</a-scene>

代码中的 <a-sky> 就是纯色背景,其他几个看名字就知道是哪个了。
这个demo还可以用鼠标和键盘导航,如果用手机浏览器打开,就是VR效果了。

3. 从头做一个自己的场景

3.1 模板

A-Frame 的所有元素都放在 <a-scene> 中,初始代码如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8"/>
    <title>Our First A-Frame Experience</title>
    <script src="https://aframe.io/releases/0.4.0/aframe.min.js"></script>
  </head>
  <body>
    <a-scene>
      
    </a-scene>
  </body>
</html>

3.2 天空

天空使用的元素是 <a-sky>,代码如下:

<a-sky color="#C500FF"></a-sky>

此时会产生一个紫红色的天空。天空也可以是一个全景图.
flickr 有很多全景图,我们选一个作为背景,比如这一张

现在把天空换成这张全景图。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8"/>
    <title>Our First A-Frame Experience</title>
    <script src="https://aframe.io/releases/0.4.0/aframe.min.js"></script>
  </head>
  <body>
    <a-scene>
      <a-sky src="https://c1.staticflickr.com/8/7376/16218590470_468084c950_h.jpg"></a-sky>
    </a-scene>
  </body>
</html>

现在代码(在codepen.io打开代码在codepen.io预览

现在用手机看效果是不是有身临其境的感觉了。

3.3 放一个球进去

<a-scene>
  <a-sky src="https://c1.staticflickr.com/8/7376/16218590470_468084c950_h.jpg"></a-sky>
  <a-sphere position="0 1.25 -5" radius="1.25" color="#66ffcc"></a-sphere>
</a-scene>

现在场景中多了一个蓝色的球,直接看效果

球也可以不是纯色的,这就需要给球表面贴图,我们先从 subtlepatterns 选一个材质,
这一张木纹吧:

<a-scene>
  <a-sky src="https://c1.staticflickr.com/8/7376/16218590470_468084c950_h.jpg"></a-sky>
  <a-sphere position="0 1.25 -5" radius="1.25" src="https://www.toptal.com/designers/subtlepatterns/patterns/retina_wood.png"></a-sphere>
</a-scene>

效果是这样的

3.4 光标交互

VR里也有对应的交互方案,我们现在增加动画和事件库。场景里增加一个camera和放在其中的curosr。

<script src="https://aframe.io/releases/0.4.0/aframe.min.js"></script>
<script src="https://npmcdn.com/aframe-animation-component@3.0.1"></script>
<script src="https://npmcdn.com/aframe-event-set-component@3.0.1"></script>

<a-scene>
  <a-sky src="https://c1.staticflickr.com/8/7376/16218590470_468084c950_h.jpg"></a-sky>
  <a-sphere position="0 1.25 -5" radius="1.25" src="https://www.toptal.com/designers/subtlepatterns/patterns/retina_wood.png"></a-sphere>

  <!-- Camera + cursor. -->
  <a-entity camera look-controls>
    <a-cursor id="cursor" 
        animation__click="property: scale; startEvents: click; from: 0.1 0.1 0.1; to: 1 1 1; dur: 150"
        animation__fusing="property: fusing; startEvents: fusing; from: 1 1 1; to: 0.1 0.1 0.1; dur: 1500"
        event-set__1="_event: mouseenter; color: #0092d8" 
        event-set__2="_event: mouseleave; color: black"></a-cursor>
  </a-entity>
</a-scene>

现在代码(在codepen.io查看代码在codepen.io预览)。

现在随着视口(摄像机)移动,在屏幕中央的光标(定位相对于摄像机固定)会跟着摄像机移动。

光标与鼠标一样遇到圆球,触发 mouseenter 事件,离开圆球触发 mouseleave 事件。现在我们已经增加了这两个事件,进入的时候光标变蓝色,离开变回默认的黑色。点击时伴随有光标缩放效果。

3.5 与场景中的球互动

就像鼠标点击按钮一样,这个光标也可以触发场景中的元素事件,不过这时就没有现成的代码库了,要自己写事件绑定。

<script src="https://aframe.io/releases/0.4.0/aframe.min.js"></script>
<script src="https://npmcdn.com/aframe-animation-component@3.0.1"></script>
<script src="https://npmcdn.com/aframe-event-set-component@3.0.1"></script>
<script>
    AFRAME.registerComponent('hide-on-click', {
        dependencies: ['raycaster'],
        schema: {
            target:{type: 'selector'}
        },
        init: function () {
            var data = this.data;
            var el = this.el;
            el.addEventListener('click', function () {
                el.setAttribute('visible', false);
                data.target.setAttribute('visible', true);
            });
        }
    });
</script>

<a-scene>
  <a-sky src="https://c1.staticflickr.com/8/7376/16218590470_468084c950_h.jpg"></a-sky>
  <a-sphere hide-on-click="target:#another_cube" position="0 1.25 -5" radius="1.25" src="https://www.toptal.com/designers/subtlepatterns/patterns/retina_wood.png"></a-sphere>
  <a-box id="another_cube" visible="false" position="-1 1.5 -4" rotation="0 45 0" width="1" height="1" depth="1" color="#4CC3D9"></a-box>

  <!-- Camera + cursor. -->
  <a-entity camera look-controls>
    <a-cursor id="cursor" 
        animation__click="property: scale; startEvents: click; from: 0.1 0.1 0.1; to: 1 1 1; dur: 150"
        animation__fusing="property: fusing; startEvents: fusing; from: 1 1 1; to: 0.1 0.1 0.1; dur: 1500"
        event-set__1="_event: mouseenter; color: #0092d8" 
        event-set__2="_event: mouseleave; color: black"></a-cursor>
  </a-entity>
</a-scene>

现在代码(在codepen.io打开代码在codepen.io预览)。

现在如果用PC浏览器看效果,鼠标左右拖动能移动摄像机(同时移动光标),鼠标单击任意位置,光标触发点击事件。点击圆球以后,圆球消失,正方体的盒子出现了。

4 做一个互动游戏

我的游戏过程是,进入页面在随机位置生成一个鸡蛋,用户用光标点击鸡蛋,鸡蛋隐藏,然后播放鸡蛋破壳动画,最后触发弹窗,提示用户你获得一只鸡。

4.1 基础代码

跟上面的差不多,只是去掉了绑定事件和圆球。因为球形太圆了,不像鸡蛋。只能用一张静态的鸡蛋图片代替。为什么不用椭圆形的3d元素呢,因为除了方形、圆柱、圆形、平面以外的物体,多边形太多了,占用内存太大。

要展示平面图形就需要另外一个库 aframe-html-shader。

<script src="https://aframe.io/releases/0.4.0/aframe.min.js"></script>
<script src="https://npmcdn.com/aframe-animation-component@3.0.1"></script>
<script src="https://npmcdn.com/aframe-event-set-component@3.0.1"></script>
<script src="https://npmcdn.com/aframe-layout-component@3.0.1"></script>
<script src="https://rawgit.com/mayognaise/aframe-html-shader/master/dist/aframe-html-shader.min.js"></script>

<script>
    AFRAME.registerComponent('hide-on-click', {
        dependencies: ['raycaster'],
        schema: {
            target:{type: 'selector'}
        },
        init: function () {
            var data = this.data;
            var el = this.el;
            el.addEventListener('click', function () {
                el.setAttribute('visible', false);
                data.target.setAttribute('visible', true);
                
            });
        }
    });
</script>

<div id="textToDisplay"><img src="./frame_1.png" alt=""></div>
<a-scene>
  <a-sky src="https://c1.staticflickr.com/8/7376/16218590470_468084c950_h.jpg"></a-sky>
  
    <a-entity hide-on-click="target:#chickenAnimate">
        <a-entity id="default_egg"
                  geometry="primitive:plane;width:2;height:2;"
                  position="-3 2 -10"
                  material="shader:html;target:#textToDisplay;transparent:true;fps:0;">
        </a-entity>
    </a-entity>
    <a-box id="chickenAnimate" visible="false" position="-1 1.5 -4" rotation="0 45 0" width="1" height="1" depth="1" color="#4CC3D9"></a-box>


  <!-- Camera + cursor. -->
  <a-entity camera look-controls>
    <a-cursor id="cursor" 
        animation__click="property: scale; startEvents: click; from: 0.1 0.1 0.1; to: 1 1 1; dur: 150"
        animation__fusing="property: fusing; startEvents: fusing; from: 1 1 1; to: 0.1 0.1 0.1; dur: 1500"
        event-set__1="_event: mouseenter; color: #0092d8" 
        event-set__2="_event: mouseleave; color: black"></a-cursor>
  </a-entity>
</a-scene>

上面的代码改动增加了一个div#textToDisplay 里面只有一张鸡蛋的图片,这张图会用来渲染到 a-entity#default_egg。语法是设置 matrial 的值。具体可参考
github aframe-html-shader 项目主页

这里有个问题,aframe-html-shader 原理是用canvas渲染DOM中的元素,如果有图片会需要由canvas处理,这就涉及到跨域问题。

跨域问题可以通过html2canvas-php-proxy解决,
但是在服务器部署这个代理有安全风险,所以我们的demo放到同一个域测试。

演示地址在: demo7

此时用光标点击鸡蛋,鸡蛋会消失,正方体出现。

4.2 播放动画

鸡蛋的动画也需要用canvas渲染出来,这里再引入一个库:

aframe-gif-shader github 地址

<script src="https://aframe.io/releases/0.5.0/aframe.min.js"></script>
<script src="https://npmcdn.com/aframe-animation-component@3.0.1"></script>
<script src="https://npmcdn.com/aframe-event-set-component@3.0.1"></script>
<script src="https://npmcdn.com/aframe-layout-component@3.0.1"></script>
<script src="https://rawgit.com/mayognaise/aframe-html-shader/master/dist/aframe-html-shader.min.js"></script>
<script src="./aframe-gif-shader.js"></script>

然后把替代的正方体盒子换成孵蛋动画:

<a-entity id="chickenAnimate" visible="false">
    <a-entity id="animate_egg" geometry="primitive:plane;width:2;height:2;"
              position="-3 2 -10"
              material="shader:gif;src:url(./egg_2017_1.gif);transparent:true;"
              gif=""></a-entity>
</a-entity>

此处的gif渲染原理是用js加载并解析每一帧动画,并用canvas渲染出来。

这里有两个小坑:

  • aframe-gif-shader 组件中渲染每一帧之前,忘记清空画布,导致影响重叠。(难怪demo使用的都是不透明gif图片)我已经修复了这个问题,所以引用的是本地文件。
  • aframe-gif-shader 组件中使用了第三方的优化算法,但是这个优化算法存在bug,连续的两帧图片上部透明部分太多会被认为是多余帧优化掉。此处可以修改gif素材绕过。

脚本也增加一点内容,播放孵蛋的动画后,等3秒弹出alert,提醒用户游戏结果,然后重置游戏。

AFRAME.registerComponent('hide-on-click', {
    dependencies: ['raycaster'],
    schema: {
        target: {type: 'selector'}
    },
    init: function () {
        var data = this.data;
        var el = this.el;
        el.addEventListener('click', function () {
            el.setAttribute('visible', false);
            data.target.setAttribute('visible', true);
            setTimeout(function(){
                alert('恭喜您获得一只鸡!');
                window.location.reload();
            },5000);
        });
    }
});

完整的效果和源码请查看: demo8

4.3 增加趣味性,随机位置

现在游戏可以玩了,但是每次鸡蛋都处在统一位置,根本不用找。为了增加趣味性,我们随机改变鸡蛋的位置。
但是根据我的测试,完全随机的游戏一点都不好玩,太远了会变成几个像素大小,根本找不到,太近了有种糊在脸上的感觉。还有上下左右位置都不能太离谱。
多次测试后决定把位置放在用户站的位置周围固定距离的圆圈上,高度比身高多一点点的时候,比较合理。

我翻出来初中数学课本,找到了下面的公式:

var R = Math.random() * 360; // 弧度
var r = 8; // 半径
var X = Math.sin(R) * r; // x 坐标
var Z = Math.cos(R) * r; // 俯视图 z 坐标

$('#default_egg,#animate_egg').attr('position',{ x: X, y: 2, z: Z });

这下位置随机了,可是鸡蛋是平面图形,朝向必须面对摄像头才行,不然会有一种挨着窗户坐在教室第一排看黑板的感觉。

这时就需要另外一个库,从官方插件库中分出来的 kframe 插件库,其中有个 look-at 插件,看名字就知道作用就是让元素面对镜头。加上后的js引用有这么多:

<script src="https://aframe.io/releases/0.5.0/aframe.min.js"></script>
<script src="https://npmcdn.com/aframe-animation-component@3.0.1"></script>
<script src="https://npmcdn.com/aframe-event-set-component@3.0.1"></script>
<script src="https://npmcdn.com/aframe-layout-component@3.0.1"></script>
<script src="https://rawgit.com/mayognaise/aframe-html-shader/master/dist/aframe-html-shader.min.js"></script>
<script src="./aframe-gif-shader.js"></script>
<script src="./aframe-look-at-component.min.js"></script>

这里使用本地版本是没找到 cdn 地址的。

元素上也加上属性绑定:

<a-entity hide-on-click="target:#chickenAnimate">
    <a-entity id="default_egg"
              geometry="primitive:plane;width:2;height:2;"
              look-at="[camera]"
              position="-3 2 -10"
              material="shader:html;target:#textToDisplay;transparent:true;fps:0;">
    </a-entity>
</a-entity>
<a-entity id="chickenAnimate" visible="false">
    <a-entity id="animate_egg" geometry="primitive:plane;width:2;height:2;"
              look-at="[camera]"
              position="-3 2 -10"
              material="shader:gif;src:url(./egg_2017_1.gif);transparent:true;"
              gif=""></a-entity>
</a-entity>

此时已经可以完整的游戏了,源代码和演示都在: demo9

4.4 再增加点趣味性,AR?

现在浏览器的运算能力还做不到通过摄像头识别复杂的内容,最多只能识别有限的几个二维码。但是我们也可以退而求其次,先把场景放到用户周围再说。

我们调用摄像头,作为网页的背景就能模拟显示场景了。

iOS 系统没有开放摄像头的权限,只能通过android手机测试了。

我们再引用一个 threex.webar库中的一个threex.webcamgrabbing组件。

etUserMedia() no longer works on insecure origins.
To use this feature, you should consider switching your application to a secure origin,
such as HTTPS. See https://goo.gl/rStTGz for more details.

悲剧啊,过完年 MediaStreamTrack.getSources 接口被Chrome抛弃了,微信X5也停止支持了,但是新的接口navigator.mediaDevices.getUserMedia旧手机不支持。

所以我们使用 webrtc adapter 兼容新版本的获取资源方法。自己实现获取摄像头内容。

navigator.mediaDevices.getUserMedia 接口只能在 HTTPS 地址中被调用。

就是游戏的最终版本: demo10

微信里要点最下面的,访问原网页。

最终版做了判断,支持获取摄像头的设备就使用当前环境做背景,不支持的就在小树林里找蛋。

到这里这个游戏就做出来了,如果想要做成产品给用户玩,还需要做很多美化工作。至少要有加载游戏前的Loading页面,游戏界面的装饰效果,提示浮层美化,游戏后台接口记录成绩,给用户发奖品等工作。


参考资料

读懂Underscore.js【1】

Underscore是一个JavaScript函数库,在不修改原生对象的基础上提供了很多支持函数式编程的工具。官方文档已经详细的介绍了使用方法,还有大量的栗子。还有加了注释的源代码

Underscore有80多个函数,使用Backbone.js时发现了这个精巧的函数库。这里的javascript templating可以单独拿出来用到别的小项目中,快速生成前端页面。其它还有forEachmapindexOf等Array操作中常用但是原生js中没有的功能。最近的新版本中还增加了函数绑定等新功能。

阅读源码时发现我的js基础不是一般的差,下面就是记录我看不懂到看懂的过程。

each _.each(list, iterator, [context]) Alias: forEach

遍历List,用其中的每个值生成一个iterator对象。如果传入了context,则把iterator绑定到context对象上。每次调用iterator对象都会传入三个参数:(element, index, list),如果List是一个JavaScript对象,则传入参数:(value, key, list)。如果有原生的forEach(ECMAScript 5)方法,则会调用原生方法。

  var each = _.each = _.forEach = function(obj, iterator, context) {
    if (obj == null) return;
    if (nativeForEach && obj.forEach === nativeForEach) { //【1】
      obj.forEach(iterator, context);
    } else if (obj.length === +obj.length) { // 【2】【3】
      for (var i = 0, l = obj.length; i < l; i++) {
        if (iterator.call(context, obj[i], i, obj) === breaker) return; // 【4】【5】
      }
    } else {
      for (var key in obj) {
        if (_.has(obj, key)) {
          if (iterator.call(context, obj[key], key, obj) === breaker) return;
        }
      }
    }
  };

【1】//nativeForEach = Array.prototype.forEach

【2】 一元运算符+

1. Let expr be the result of evaluating UnaryExpression.
2. Return ToNumber(GetValue(expr)).

【3】这里只检查length属性,这样会有问题的吧,不只是Array,argument才有length属性,如果用户自己构造了一个带length属性的对象,
这样的结果会很意外吧:

function show(val,key,o){
    console.log(val);
    console.log(key);
    console.log(o);
    console.log('----');
}

var obj = {
    "length":3,
    "var1":"one",
    "var4":"four"
};
_.each(obj,show);

执行结果见jsbin

undefined
0
Object {length: 3, var1: "one", var4: "four"}
----
undefined
1
Object {length: 3, var1: "one", var4: "four"}
----
undefined
2
Object {length: 3, var1: "one", var4: "four"}
----

【4】Function.prototype.call
Function.prototype.call(thisArg [ , arg1 [ , arg2, … ] ] )

call将指定函数Function作为thisArg对象的方法来调用,将参数args传递给Function,返回值为Function的返回值。

【5】神秘的breaker

关于underscore.js中断枚举Github有好长的讨论
Stackoverflow有个回答是简略版

用一个秘密的变量来中断each循环,这个变量是underscore的内部变量。
不暴露在外的原因是原生方法中(目前)没有这个特性,如果这样做了,
就会导致(用户写的代码中的)中断特性只有在原生函数不支持的时候才能用

不过看源码是先尝试用原生的forEach方法,既然原生不支持,那执行到这里的时候就不能中断了?

待续……

几个使用jQuery的图片预加载函数

最近项目中用到的一个功能,用户进入网站时显示loading页面,直到主页的几个大图片加载完成才渐隐进入主页。自己写了个插件,看起来结构挺糟糕的,不好意思放到项目里。在网上搜现成的,还挺多。不过得用英文关键词,搜中文的真是垃圾网站一大堆。废话完毕下面开始

第一个

(function($) {
  var cache = [];
  // Arguments are image paths relative to the current page.
  $.preLoadImages = function() {
    var args_len = arguments.length;
    for (var i = args_len; i--;) {
      var cacheImage = document.createElement('img');
      cacheImage.src = arguments[i];
      cache.push(cacheImage);
    }
  }
})(jQuery)

使用方法

jQuery.preLoadImages("image1.gif", "/path/to/image2.png");

这个来自这里:Engineered Web。可以看到其实这个是用原生JavaScript实现,使用jQuery是为了调用方便。
优点:原生JavaScript,速度快。
缺点:不支持回调函数。

第二个

// Helper function, used below.
// Usage: ['img1.jpg','img2.jpg'].remove('img1.jpg');
Array.prototype.remove = function(element) {
  for (var i = 0; i < this.length; i++) {
    if (this[i] == element) { this.splice(i,1); }
  }
};

// Usage: $(['img1.jpg','img2.jpg']).preloadImages(function(){ ... });
// Callback function gets called after all images are preloaded
$.fn.preloadImages = function(callback) {
  checklist = this.toArray();
  this.each(function() {
    $('<img>').attr({ src: this }).load(function() {
      checklist.remove($(this).attr('src'));
      if (checklist.length == 0) { callback(); }
    });
  });
};

使用方法

$.post('/submit_stuff', { id: 123 }, function(response) {
  $([response.imgsrc1, response.imgsrc2]).preloadImages(function(){
    // Update page with response data
  });
});

第二个函数来自 stackoverflow
这个支持了回调函数,全部图片都加载完成后执行,我的项目中就是用的这个。
作者为了方便扩展了Array对象,使用时注意不要影响到其他的代码。
搜索的时候还发现一个更好用的:

第三个

(function($) {
	var imgList = [];
	$.extend({
		preload: function(imgArr, option) {
			var setting = $.extend({
				init: function(loaded, total) {},
				loaded: function(img, loaded, total) {},
				loaded_all: function(loaded, total) {}
			}, option);
			var total = imgArr.length;
			var loaded = 0;

			setting.init(0, total);
			for(var i in imgArr) {
				imgList.push($("<img />")
					.attr("src", imgArr[i])
					.load(function() {
						loaded++;
						setting.loaded(this, loaded, total);
						if(loaded == total) {
							setting.loaded_all(loaded, total);
						}
					})
				);
			}

		}
	});
})(jQuery);

调用方法:

$(function() {
    $.preload([
        "http://farm3.static.flickr.com/2661/3792282714_90584b41d5_b.jpg",
        "http://farm2.static.flickr.com/1266/1402810863_d41f360b2e_o.jpg"
    ], {
        init: function(loaded, total) {
            $("#indicator").html("Loaded: "+loaded+"/"+total);
        },
        loaded: function(img, loaded, total) {
            $("#indicator").html("Loaded: "+loaded+"/"+total);
            $("#full-screen").append(img);
        },
        loaded_all: function(loaded, total) {
            $("#indicator").html("Loaded: "+loaded+"/"+total+". Done!");
        }
    });
});

这个来自这里 http://ditio.net,貌似是作者的一个插件中的一段代码。
支持三个回调函数:加载前、单个图片加载完成、全部图片加载完成。原作者还给了个演示网页,看演示网页的源代码就明白了。