实战篇 5:为天气页面制作雨雪效果的粒子系统¶
经过前两节的内容,基本天气预报页面的布局和数据交互都已经完成了,本节来介绍使用小程序的绘图 API 在「实时天气」模块上做一个雨雪效果。
小程序的绘图 API¶
小程序的绘图 API 跟 HTML5 的 Canvas 本质上有很大区别的,造成用法区别的原因是:
小程序的绘图(Canvas)是客户端实现的 Native UI 组件,而不是普通的 H5 组件,所以在使用上跟普通的 H5 组件用法略有不同。
Tips: 微信的 Canvas 在 iOS 上是 Ejecta 实现的。
上下文获取方式不同¶
小程序绘图 API 的 canvasContext 获取方式是通过 <canvas> 的 canvas-id 来获取的,即
<canvas canvas-id="test"></canvas>
获取 Context:
let ctx = wx.createCanvasContext('test')
API 写法不同¶
小程序的绘图 API 跟 HTML5 的 Canvas 在用法上主要是绝大部分的 HTML5 Canvas 属性写法,变成了小程序的方法写法,例如:
const ctx = wx.createCanvasContext('myCanvas')
ctx.setFillStyle('red')
ctx.fillRect(10, 10, 150, 75)
ctx.draw()
不过值得一提的是,在 1.9.0 基础库以上,类似 fillStyle、lineWidth 这类的,可以直接跟 H5 的写法一样,不需要使用 setXxxx 的方式了。
想要显示绘制效果,需要 ctx.draw() 使用¶
在小程序的绘图使用中,对 context 进行绘制之后,并不会立即绘制到画布上,而是通过执行 ctx.draw() 的方式,将之前在绘图上下文中的描述(路径、变形、样式)画到 canvas 中。ctx.draw() 方法比较消耗性能,因此不建议在一个绘制周期内多次调用。
Tips: 小程序绘图中的内部尺寸单位都是 px,例如
clearRect()这类方法,所以在使用rpx布局的页面中,需要注意 Canvas 内部 rpx 到 px 的转换关系,详细解释见本节粒子系统实现部分。
粒子系统设计思路¶
在 Canvas 开发中,经常会提到粒子系统,使用粒子系统可以模拟出火、雾、云、雪、尘埃、烟气等抽象视觉效果。
在这个小程序中,笔者使用粒子系统做了雨雪效果,通过雨雪效果的编写,可以让读者学会粒子系统的基础知识,以及在小程序中使用绘图 API 相关的接口。
本小册中的粒子系统由基类和子类组成。Particle 是基类,定义了子类统一的方法,如 run()、stop()、clear() 等。基类负责整个粒子系统动画周期和流程的维护,子类负责具体实现的粒子效果,比如下雨下雪的效果是子类实现的,而下雨下雪的开关和公共处理流程是基类控制的。
基类由如下几个方法组成:
_init():实例化时第一执行的方法;空,由子类具体实现_draw():每个动效周期内画图用的方法;空,由子类具体实现run:设置定时器,定时执行_draw(),实现动画周期stop:停止动画clear:停止动画,并且清空画板
这些方法之间的关系是:
上面的关系图很清晰地展现了整个粒子系统的设计思路:
- 在构造器内调用
_init,随机生成单个粒子,放进数组对象 - 在执行实例
run的时候,设置定时器,定时器回调调用_draw绘制粒子,设置单个粒子下一步的属性 - 而
_init和_draw是子类具体根据效果实现的
根据这个关系图,基类就很简单实现了:
// lib/effect.js
// 两个状态
const STATUS_STOP = 'stop'
const STATUS_RUNNING = 'running'
class Particle {
constructor(ctx, width, height, opts) {
this._timer = null
this._options = opts || {}
// canvas 上下文
this.ctx = ctx
this.status = STATUS_STOP
this.w = width
this.h = height
this._init()
}
_init() {}
_draw() {}
run() {
if (this.status !== STATUS_RUNNING) {
// 更改状态
this.status = STATUS_RUNNING
// 绘制循环
this._timer = setInterval(() => {
this._draw()
}, 30)
}
return this
}
stop() {
// 清理定时器,状态修改
this.status = STATUS_STOP
clearInterval(this._timer)
return this
}
clear(){
this.stop()
this.ctx.clearRect(0, 0, this.w, this.h)
this.ctx.draw()
return this
}
}
下雨效果的粒子系统¶
根据上面的内容,具体的子类只需要在 _init 中,根据需要生成的粒子个数 amount 循环随机生成每个粒子,放入 this.particles 数组即可:
// lib/effect.js
// _init
let h = this.h
let w = this.w
// 数量,根据不同雨大小,数量可调
let amount = this._options.amount || 100
// 速度参数,调节下落速度
let speedFactor = this._options.speedFactor || 0.03
let speed = speedFactor * h
let ps = (this.particles = [])
for (let i = 0; i < amount; i++) {
let p = {
x: Math.random() * w,
y: Math.random() * h,
l: 2 * Math.random(),
xs: -1,
ys: 10 * Math.random() + speed,
color: 'rgba(255, 255, 255, 0.1)'
}
ps.push(p)
}
其中: * x、y 代表单个粒子的位置,即雨滴开始绘图的位置 * xs、ys 分别代表 x、y 方向上的加速度,即雨滴的下落速度和角度 * l 代表雨滴的长度
_draw的方法,是先将画布清空,然后遍历 this.particles 数组取出单个雨滴并进行绘制,最后调用一个单独实现的 _update 重新计算单个雨滴的位置:
// lib/effect.js
// _draw
let ps = this.particles
let ctx = this.ctx
// 清空画布
ctx.clearRect(0, 0, this.w, this.h)
// 遍历绘制雨滴
for (let i = 0; i < ps.length; i++) {
let s = ps[i]
ctx.beginPath()
ctx.moveTo(s.x, s.y)
// 画线绘制雨点效果
ctx.lineTo(s.x + s.l * s.xs, s.y + s.l * s.ys)
ctx.setStrokeStyle(s.color)
ctx.stroke()
}
ctx.draw()
return this._update()
_update 的具体实现如下:
// lib/effect.js
// _update
let {w, h} = this // 获取画布大小
for (let ps = this.particles, i = 0; i < ps.length; i++) {
// 开始下一个周期的位置计算
let s = ps[i]
s.x += s.xs
s.y += s.ys
// 超出范围,重新回收,重复利用
if (s.x > w || s.y > h) {
s.x = Math.random() * w
s.y = -10
}
}
下雪效果子类实现¶
下雪的效果跟下雨不同的是,下雨是长条的线,雪花是圆形的雪片,另外为了增加「灵性」做出飘来飘去的效果,在 _update 方法中,使用了 Math.cos 来随机生成下一步 x 轴的位置,这里就直接贴出代码来:
// lib/effect.js
class Snow extends Particle {
_init() {
let {w, h} = this
let colors = this._options._colors || ['#ccc', '#eee', '#fff', '#ddd']
// 雪的大小用数量来计算
let amount = this._options.amount || 100
let speedFactor = this._options.speedFactor || 0.03
// 速度
let speed = speedFactor * h * 0.15
let radius = this._options.radius || 2
let ps = (this.particles = [])
for (let i = 0; i < amount; i++) {
let x = Math.random() * w
let y = Math.random() * h
// console.log(x, y)
ps.push({
x,
y,
// 原始 x 坐标,后面计算随机雪摆动是以此为基础
ox: x,
// 向下运动动能变量
ys: Math.random() + speed,
// 雪的半径大小
r: Math.floor(Math.random() * (radius + 0.5) + 0.5),
// 颜色随机取
color: colors[Math.floor(Math.random() * colors.length)],
rs: Math.random() * 80
})
}
}
_draw() {
let ps = this.particles
let ctx = this.ctx
ctx.clearRect(0, 0, this.w, this.h)
for (let i = 0; i < ps.length; i++) {
let {x, y, r, color} = ps[i]
ctx.beginPath()
// 绘制下雪的效果
ctx.arc(x, y, r, 0, Math.PI * 2, false)
ctx.setFillStyle(color)
ctx.fill()
ctx.closePath()
}
ctx.draw()
this._update()
}
_update() {
let {w, h} = this
let v = this._options.speedFactor / 10
for (let ps = this.particles, i = 0; i < ps.length; i++) {
let p = ps[i]
let {ox, ys} = p
p.rs += v
// 这里使用了 cos,做成随机左右摆动的效果
p.x = ox + Math.cos(p.rs) * w / 2
p.y += ys
// console.log(ys)
// 重复利用
if (p.x > w || p.y > h) {
p.x = Math.random() * w
p.y = -10
}
}
}
}
注意,不管是下雨还是下雪,在 _draw 的最开始都是执行 ctx.clearRect 清空画布,最后都是执行 ctx.draw 使 native 对画布进行统一绘制。
使用粒子系统¶
上面介绍了雨雪的粒子系统 JS 类实现,下面讲解怎样将 Canvas 效果画到网页上,首先看下效果图。
效果图的黄色框内为下雨的效果,这个红色框大小跟顶部「实时天气模块」是等大的。首先,在 WXML 代码中,给实时天气模块增加 id 为 effect 的 Canvas 组件:
<!-- weather/index.wxml -->
<view class="container" id="canvas-wrapper">
<!-- 下面是雨雪效果的 Canvas -->
<canvas canvas-id="effect" id="effect"></canvas>
<view class="now">
<view class="location" bindtap="chooseLocation">
...
</view>
<view class="air-quality" wx:if="{{air.aqi}}">
...
</view>
<view class="now-weather">
...
</view>
</view>
<view class="two-days">
....
</view>
</view>
在样式中,设置 Canvas 的大小跟实时天气模块大小一样,并且绝对定位,完全覆盖到实时天气模块上:
// weather/index.scss
#effect {
width: 750rpx;
height: 768rpx;
position: absolute;
top: 0;
right: 0;
}
重点:在微信小程序内,绘图 API(Canvas)内的长宽单位为 px,而我们页面布局用的是 rpx,虽然我们在 CSS 内已经使用 rpx 设置了 Canvas 的大小,但是由于内部单位的缘故,在实例化 Rain/Snow 粒子系统的时候,传入的 width 和 height 参数应该是实际的 px 大小。
根据之前章节的介绍,rpx 转 px 是根据不同的设备屏幕尺寸转换的。虽然切图可以按照 1rpx=2px 这样标准的 iPhone 6 视觉稿做页面,但是涉及实际 px 计算时,不能简单采用 1rpx=2px 的方式来解决,需要我们按照实际的 rpx 对应 px 的比例进行转换。如何获取 rpx 和 px 的实际比例呢?我们知道微信小程序中默认规定了屏幕宽度为 750rpx,根据这个设计,我们可以通过 wx.getSystemInfo 获取到的信息,找到手机屏幕的宽度大小 windowWidth 即可算出对应的比例,代码如下:
// weather/index.js
// 在 onload 内
wx.getSystemInfo({
success: (res) => {
let width = res.windowWidth
this.setData({
width,
scale: width / 375
})
}
})
这样,上面的 width就是屏幕的实际 px 宽度,而每个元素的实际 **px 高度**则由 元素 rpx 高度 / 2 * scale 得到。
最后,我们在页面代码中,实际使用 Rain/Snow 类时的代码是下面这样的:
// weather/index.js
// 下面是 canvas 的 canvas-id
const canvasId = 'effect'
const ctx = wx.createCanvasContext(canvasId)
let {width, scale} = this.data
// 768 为 CSS 中设置的 rpx 值
let height = 768 / 2 * scale
let rain = new Rain(ctx, width, height, {
amount: 100,
speedFactor: 0.03
})
// 跑起来
rain.run()
// 如果切换了城市,不在下雨/雪,则执行
rain.clear()
小结¶
本节介绍使用小程序绘图 API(Canvas)绘制雨雪效果的粒子系统,给整个天气页面添加动效。
在使用粒子系统中应该注意小程序绘图 API 的写法跟 HTML5 中 Canvas API 的差异。除了 API 的差异,还要在绘图结束后调用 ctx.draw() 使绘制执行。最后介绍了怎样在 rpx 的布局中绘制不定宽高的效果,需要根据屏幕的宽度跟 rpx 的实际比例计算出元素的实际 px 宽高,然后给 Canvas 使用。