我们的业务方需求制作一个英语学习类答题 PK 小游戏,同时投放到主 App 以及微信平台给站内的用户以及站外的用户去使用。在主 App 内以 H5 的形式呈现,而在微信平台则是小游戏的形式呈现。虽然是小游戏,整体的内容也只是若干个场景以及一些交互动画,任何平台的实现也不会是太复杂的工作,所以以熟悉的开发方式、最低的整体开发成本来实现业务需求,同时兼顾代码的未来移植的便利性也成了开发在技术选型时需要考量的事。
二. 技术选型
微信小游戏的开发是基于暴露的 canvas 绘图接口来实现图形绘制和交互,而 H5 场景除了使用 canvas 元素作为画布绘制小游戏,同时也可以使用普通的 Dom 元素和 CSS 动画来实现小游戏。兼顾两平台考虑,使用 canvas 图形绘制实现小游戏是不错的选择。
在 2D 图形渲染工具领域,cocos-creator 与 pixi.js 都是佼佼者,都提供了丰富的 API、组件来提高开发效率,同时支持在渲染环境支持 WebGL 的情况优先使用 WebGL 来加速渲染。不过 pixi.js 社区提供了基于 React 自定义渲染 API 的 react-pixi-fiber 组件,让开发者以熟悉的 React 开发模式来使用 pixi.js ,即把 React 作为 UI 运行时,负责状态管理、逻辑运算、路由等,而 pixi.js 来在画布上基于 React 运算获得的VDom结构来绘制图形,这其中的细节开发者无需关注,而只需专注于 React 组件式开发。所以最终我们选择 pixi.js 作为图形渲染库,使用 React 开发所需要的组件和游戏场景。
整体结构图
三. 开发实践
3.1 项目结构
├─build // 脚本
│ ├─webpack.config.js // webpack 配置
├─public // H5资源
│ ├─index.html // H5 HTML模板
├─src // 开发目录
│ ├─assets // 资源
│ ├─components // 组件
│ ├─pages // 场景页面
│ │ ├─entry // 场景1
│ │ └─flow // 场景2
│ │ └─...
│ ├─app.tsx // 游戏入口
| └─routes.ts // 路由配置
├─project.config.json // 小游戏工程配置
├─game.json // 小游戏入口配置JSON
├─game.js // 小游戏入口
由于小程序和 H5 入口形式不统一,将两者的入口分离,通用的逻辑代码存放到开发 src 目录。
3.2 工程化开发/构建
小游戏代码开发默认是 Bundleless 形式,由于本例中,采用了 React 视图的开发模式,如 JSX 语法等,需要通过构建的方式转化为各平台适用的代码。因此引入了 webpack 构建工具,在开发模式下,对于小程序调试场景,需要将 devServer 的内存文件转变成磁盘文件,以确保小程序能够实时更新。
// webpack.config.js
{
...
devServer: {
writeToDisk: true, // 输出文件到磁盘
...
}
}
在构建上,通过设置构建目标的环境变量,并基于此环境变量调整不同平台的差异配置。
// package.json
{
"scripts": {
"build:h5": "BUILD_TARGET=h5 webpack --config build/webpack.config.js",
"build:minigame": "BUILD_TARGET=minigame webpack --config build/webpack.config.js"
}
}
// webpack.config.js部分示例
const target = process.env.BUILD_TARGET || 'minigame';
module.exports = {
output: {
// ...
filename: target === 'minigame' ? '[name].js' : '[name].[contenthash:8].js',
},
plugins: [
// ...
target === 'minigame' ? null : new HTMLWebpackPlugin({})
].filter(item => !!item)
// ...
}
3.3 路由和入口设计
小游戏是单入口的形式,且没有页面的概念,取而代之的是场景。但是这里我们仍然需要引入路由管理,主要原因和目的如下:
符合 React 项目开发的习惯,能够更方便地组织文件和进行代码分割
在小游戏场景不存在页面路径的概念,但是又存在分享指定场景的需求
所以,在本项目中实现了一种特殊的路由管理,因为其不能对外直接进行路径分发,所以称之为虚拟路由。虚拟路由需要依赖一个着陆页,用户不论以何种形式进入小游戏,都是首先达到着陆页,在此页面的功能中,它不承担游戏内容渲染逻辑,而是主要通过判断用户来源以及目标场景,进行路由跳转到达内容场景。在路由的实现上,采用了内存 history 的形式,到达入口后,小程序通过 getLaunchOptionsSync,H5 通过 url search 获取约定的场景参数,并通过路由转场至目标场景,如果没有设置目标场景,则默认跳转至游戏首页。此外,着陆页还承担了加载器的工作,实际上是将各个场景的一些重复代码抽离出来,统一处理用户登录、信息获取以及游戏资源的加载等工作。
路由处理流程图
路由代码实现如下,和常规的 React 页面路由没有太大差异:
import React from 'react';
import { Route, Router } from 'react-router';
import { createMemoryHistory } from 'history';
const history = createMemoryHistory();
function App() {
return (
<Router history={history}>
<Route exact key="/" path="/" component={SceneLoad} />
<Route exact key="/scene-home" path="scene-home" component={SceneHome} />
{/* ... */}
</Router>
);
}
有了路由和场景代码之后,还有重要的一步是创建舞台,舞台是所有 pixi.js 渲染内容的容器,舞台创建之后,将其绑定到各平台对应的画布上,最终内容才会呈现到用户面前。以下为示例的入口初始化代码:
import React from 'react';
import { render } from 'react-pixi-fiber';
import * as PIXI from 'pixi.js-legacy';
const { Application } = PIXI;
PIXI.settings.ROUND_PIXELS = true;
const DPR = window.devicePixelRatio;
const app = new Application({
view: canvas, // 对应平台的canvas容器
width: 375 * DPR,
height: 667 * DPR,
antialias: true,
autoDensity: true,
});
render(<App />, app.stage);
3.4 组件开发
虽然是使用 React 开发,但是并没有在编译上实现从常见的 HTML 元素到宿主元素的映射转换,所以不能直接以常规的 div 这种标签形式书写各类自定义组件。当前场景下,React 渲染机已经从常规的 react-dom 替换为 react-pixi-fiber,因此需要使用由 react-pixi-fiber 提供的各类宿主组件,并基于这些宿主组件进行自定义组件的封装。
现在通过代码演示一个简单的加载进度条的实现,比较原生的 pixi.js 式写法与借助 react-pixi-fiber 的 JSX 式写法。
pixi.js 原生
import { DPR } from '@/utils/styles';
import * as PIXI from 'pixi.js-legacy';
const wrapper = new PIXI.Container();
const bgSprite = new PIXI.Sprite(PIXI.Texture.WHITE);
bgSprite.width = 335 * DPR;
bgSprite.height = 16 * DPR;
bgSprite.tint = 0x1e127f;
bgSprite.alpha = 0.13;
const innerWrapper = new PIXI.Container();
const innerSprite1 = new PIXI.Sprite(PIXI.Texture.WHITE);
innerSprite1.width = progressWidth;
innerSprite1.height = 16 * DPR;
innerSprite1.tint = color[0];
innerWrapper.addChild(innerSprite1);
const innerSprite2 = new PIXI.Sprite(loadingBackground);
innerSprite2.width = 16 * DPR;
innerSprite2.height = 375 * DPR;
innerSprite2.angle = 90;
innerSprite2.anchor = new PIXI.Point(0, 1);
innerWrapper.addChild(innerSprite2);
wrapper.addChild(bgSprite);
wrapper.addChild(innerWrapper);
JSX
import React from 'react';
import { Sprite, Container } from 'react-pixi-fiber';
import { Texture, Point } from 'pixi.js-legacy';
export default function ProgressBar(props) {
const { width = 335 * DPR, height = 16 * DPR, progress } = props;
const progressWidth = React.useMemo(() => progress * width, [
progress,
width,
]);
return (
<Container width={width} height={height}>
<Sprite
width={width}
height={height}
texture={Texture.WHITE}
tint={0x1e127f}
alpha={0.13}
></Sprite>
<Container width={progressWidth} height={height}>
<Sprite
width={progressWidth}
height={height}
texture={Texture.WHITE}
tint={props.color[0]}
/>
<Sprite
height={(375 * height) / 16}
width={height}
texture={loadingBackground}
angle={90}
anchor={new Point(0, 1)}
></Sprite>
</Container>
</Container>
);
}
上面的原生 pixi.js 代码是典型的 OOP 式写法,与 JSX 模板式相比,UI 直观性较差。更大的问题是,需要不断基于当前进度更新进度条时,需要自行解决渲染代码的函数式封装达到复用、处理旧 UI 的卸载和新 UI 的渲染。这种命令式的更新方式耦合度较高,且当有多个有重叠的复杂状态变更时,如何高效的寻找合并后的变更路径也是比较复杂的工作。而后者借助 React 基于状态驱动更新的模式和相应的渲染实现库,可节省大量的开发工作量。
3.5 动画
动画是小游戏必不可少的元素,即使是 2D 小游戏,也使用了不少动画交互元素,来丰富用户的视觉体验。由于小游戏渲染在 canvas 容器内,pixi.js 库本身对动画的支持也不佳,在动画的实现上有一定的局限性,如不能使用 CSS 动画,那么接下来简要介绍和示例一些游戏内的动画实现方案。
JS 动画
使用 JS 计算动画过程中的 UI 状态并应用到 UI 上,即 JS 动画,这种方式对一些简单的动画比较实用且通用。其中的难点就是开发动画函数,不过也可以在满足需求的情况下使用一些预定的动画函数。例如小游戏中常见的按钮、弹窗回弹效果就可以使用 spring 类型动画函数。
现在举例小游戏中的一个按钮点击与释放的回弹动画效果,这里使用 react-spring 库来计算动画时序状态。
首先需要创建一个动画容器:
import React from 'react';
import { animated } from 'react-spring';
import { Container } from 'react-pixi-fiber';
const AnimatedContainer = animated(Container);
为动画容器绑定事件处理回调,回调的最终目的就是触发 UI 的状态的更新,本例中是按钮的 scale 状态。
return (
<AnimatedContainer
position={position}
pointercancel={onPointerUp}
pointerup={onPointerUp}
pointerout={onPointerUp}
pointerupoutside={onPointerUp}
pointerdown={onPointerDown}
pointermove={onPointerMove}
interactive
buttonMode
>
{children}
</AnimatedContainer>
);
使用 react-spring 计算 scale 状态,并将 scale 状态转换为 AnimatedContainer 可用的属性值。虽然单次事件回调只设置了一次 scale 状态值,react-spring 库会根据动画配置计算出一系列时间点上的多个 scale 状态值,类似于 CSS 动画中的关键帧,并不断的在既定的时间点更新至对应状态,从而形成连续的动画效果。
const { scale } = useSpring({
config: {
duration: 50,
},
scale: pointerDown ? 0.8 : 1,
});
return (
<AnimatedContainer
...
scale={scale.interpolate((t: number) => new Point(t, t))}
>
{children}
</AnimatedContainer>
);
Lottie 动画
由开发去实现 JS 动画有一些局限性,如各平台的实现不一定统一、复杂动画的实现困难,达不到设计要求等。对此,Lottie 动画是一个比较好的解决方案。由设计师去设计动画,并导出动画配置文件,由开发引入并借助各平台的 Lottie 渲染实现,可以轻松的完成一些复杂动画,并且能够保障跨平台的统一性。在小游戏中,可以使用 Lottie 的 canvas 渲染实现。
首先,需要创建 lottie 动画的容器
function Canvas(props, ref) {
const canvasRef = React.useRef(null);
const spriteRef = React.useRef(null);
React.useImperativeHandle(
ref,
() => {
canvasRef.current = wx.createCanvas();
// ...
return canvasRef.current;
},
[]
);
return (
<Sprite
{...props}
texture={!!canvasRef.current && Texture.from(canvasRef.current)}
ref={spriteRef}
/>
);
}
const LottieView = React.forwardRef(Canvas);
此容器是一个离屏画布,在接收到渲染内容并完成绘制之后,会作为素材来源提供给 Sprite 实例。
最后,加载 lottie 动画配置并应用到指定 canvas 容器上。
const { width, height, play = true, anchor, position } = props;
const lottie = React.useRef(null);
const animationRef = React.useRef(null);
React.useEffect(() => {
if (lottie.current) {
const ctx = lottie.current.getContext('2d');
const animation = lottie.loadAnimation({
renderer: 'canvas',
autoplay: false,
rendererSettings: {
context: ctx,
clearCanvas: true,
},
});
animationRef.current = animation;
return () => {
animation.destroy();
};
}
}, []);
React.useEffect(() => {
if (animationRef.current) {
animationRef.current.stop();
if (play) {
animationRef.current.play();
}
}
}, [animationRef.current, play]);
return (
<LottieView
width={width}
position={position}
anchor={anchor}
height={height}
ref={lottie}
></LottieView>
);
3.6 跨平台适配
我们希望小游戏能够一次开发,同时构建并发布到微信小游戏、H5 小游戏两个平台。虽然均采用 canvas 绘制已经避免了大量的平台差异,仍然有一些跨平台兼容的问题需要处理。
平台 API 适配
pixi.js 是设计给浏览器平台使用的 2D 图形渲染库,使用了大量的 BOM 和 DOM API,而小游戏环境本身是不存在这些 API。因此要在小程序中使用 pixi.js,需要添加部分 BOM 和 DOM API 的小程序适配器。
例如,创建一个基本 canvas 容器用于舞台挂载,在浏览器环境可以直接这样:
const canvas = document.createElement('canvas');
小程序环境没有 document,但是通过 wx 命名空间相关 API 提供了类似的功能,因此可以这样创建 canvas 容器挂载舞台:
const canvas = wx.createCanvas();
或者播放一个按钮点击效果音乐,在浏览器环境可以直接使用 audio 标签:
const audio = document.createElement('audio');
audio.src = 'foo.mp3';
audio.play();
而在小程序环境,需要使用 wx.createInnerAudioContext:
const audio = wx.createInnerAudioContext();
audio.src = 'foo.mp3';
audio.play();
为了更好的组织代码,根据适配器设计模式,应该将这些适配代码统一至平台各自的适配器库中,而对调用方暴露统一的 API。同时,在工程构建上,可以根据构建目标,动态地设置适配器 alias,只引入平台对应的适配器代码。
适配器流程图
例如,H5 请求库适配器:
// adaptor/h5/request.js
export const request = (url, params) => {
const options = {
...params,
headers: params.headers || {},
method: method ? method.toUpperCase() : 'GET',
};
if (data && method.toUpperCase() === 'POST') {
options.body = JSON.stringify(data);
}
return window.fetch(url, options);
};
小游戏请求库适配器:
// adaptor/minigame/request.js
export const request = (url, params) => {
const options = {
...params,
headers: header || {},
method: method ? method.toUpperCase() : 'GET',
};
if (data && method.toUpperCase() === 'POST') {
options.body = JSON.stringify(data);
}
return new Promise((resolve, reject) => {
wx.request({
url,
header: params.headers || {},
method: method ? method.toUpperCase() : 'GET',
success: resolve,
fail: reject,
});
});
};
在 Webpack 构建配置中,动态设置 alias:
// webpack.config.js
module.exports = {
// ...,
resolve: {
// ...
alias: {
$adaptor: resolve(src, `adaptor/${BUILD_TARGET}`),
},
},
};
在调用代码中,直接忽略平台路径使用:
import { request } from '$adaptor/request';
目前,对于常用的 BOM 及 DOM API,微信小游戏官方也提供了一份适配器库,开发者可使用此库并扩展额外的 API 适配。
分辨率适配
针对不同尺寸设备的适配,小程序有 rpx,H5 有 postcss 的 px2vw 方案,而在 canvas 绘制的场景下,坐标均按照像素定义,参照 px2vw 方案,在设计上以 375px 宽设备为原型,根据当前机型实际视图宽度通过自定义的转换方法获得各项坐标值。
// devicePixelRatio与viewpointWidth在各平台获取方式不一致
const px = (pos) => (pos * devicePixelRatio * viewpointWidth) / 375;
3.7 性能/体验优化
除了 H5 平台的一些常规优化手段外,针对小游戏特殊的渲染方式和目标平台,也积累了一些额外的优化措施。
包瘦身
微信小游戏平台上,将素材打包至游戏包内会占用大量的包体积,加剧小游戏启动的白屏耗时。因此在实践中,除了着陆页的背景图等若干素材通过本地引用外,其他素材均通过 HTTP 引入。用户进入着陆页的耗时显著缩短,而在着陆页再集中加载网络素材,并通过加载进度条和动画形式,降低用户的心理焦虑,提供更好的使用体验。
资源缓存
在微信小游戏中,部分资源下载是使用了 wx.downloadFile API,不过此 API 不像浏览器端的网络请求实现了我们需要的资源缓存策略,因此需要进行优化来降低小游戏二次进入的加载时间。
export const downloadFile = async (path, save = true) => {
const cache = await new Promise((resolve) => {
wx.getFileSystemManager().access({
path: 'your cache path', // 自定义cache规则的存储路径,
success: () => resolve(true),
fail: () => resolve(false),
});
});
if (cache) {
return {
tempFilePath: cache,
filePath: path,
};
}
return new Promise((resolve, reject) => {
wx.downloadFile({
url: path,
success: async (data) => {
if (data.statusCode !== 200) {
reject(new Error(data.errMsg));
return;
}
try {
const { tempFilePath } = data;
const filePath = save
? await saveFile(tempFilePath, path) // 缓存
: tempFilePath;
resolve({
filePath,
tempFilePath,
});
} catch (e) {
reject(e);
}
},
fail: (e) => {
reject(e);
},
});
});
};
图形性能
pixi.js 的节点实例的任何属性变更都有一定的渲染成本,减少不必要的属性变更、避免冗余的属性设值是基本原则。react-pixi-fiber 在 commitUpdate 实现中,考虑到了这一点,只对变更的属性进行调用相应的 setter,避免了性能损耗。
如变更一个节点的 alpha 属性,不优化则完全重新渲染了节点,优化后则只变更必要的 alpha 属性,没有额外的成本。
// 优化前
applyProps: (instance, oldProps, props) => {
const { x, y, width, height, fill, alpha } = props;
instance.clear();
instance.beginFill(fill);
instance.drawRect(x, y, width, height);
instance.endFill();
instance.alpha = alpha;
};
// 优化后
applyProps: (instance, oldProps, props) => {
const { x, y, width, height, fill, alpha } = props;
// ...
if (alpha !== oldProps.alpha) {
instance.alpha = alpha;
}
};
不过,在一些场景,属性值为复杂动态生成对象,例如使用 Texture 实例作为其他节点的 texture 属性、Sprite 实例作为其他节点的 mask 属性,则需要开发者通过 memorize 化减少不必要的节点实例创建。
// tint值不变化时,mask不会随着x,y属性的变更而重新实例化
const mask = useMemo(() => {
return <Sprite texture={Texture.WHITE} tint={tint}></Sprite>;
}, [tint]);
return <Container mask={mask} x={x} y={y}></Container>;
不同的 pixi.js 组件的渲染在成本上也存在差异,在图像实现上有多个选项时,选择最优的实现方式。以常用的 mask 属性为例, drawRect 填充的 Graphics 优于其他填充的 Graphics 实现,Sprite 实现的差于前两者。例如实践中比较多的圆角背景实现使用了 Graphics 实现:
const rect = useMemo(() => {
// 不使用Sprite加载素材方式,而使用Graphics实现简单的圆角背景
const g = new Graphics();
g.clear();
g.beginFill(0xffffff, alpha || 0);
g.drawRoundedRect(x, y, width, height, radius);
g.scale = new Point(1, 1);
g.pivot = new Point(0, 0);
g.endFill();
return g;
}, [x, y, radius, width, height]);
return <Container mask={rect}>{children}</Container>;
高频变更的文本,例如加载进度展示、倒计时等,使用 Text 组件渲染成本昂贵。实践中使用了 BitmapText 来取代 Text 组件,两者在 API 上没有太大差异,唯一需要注意的是如果设定了自定义字体,需要提前加载好字体文件,由于相关资源已经在着陆页加载完成,所以不影响 BitmapText 的使用。
const Countdown = ({ time }) => {
return (
<Container>
<BitmapText
text={time + 's'}
anchor={new Point(0.5, 0.5)}
position={new Point(0, px(12))}
style={{
fontWeight: 'bolder',
fill: 0xffffff,
}}
/>
</Container>
);
};
慎重设置节点的 interactive,只有希望处理交互回调的节点如按钮、可关闭遮罩等才设置 interactive=true,这样让 pixi.js 引擎的交互管理器只抓取必要的对象的交互事件。
四. 结语
通过一个2D答题PK小游戏的实践,证实了使用`React`结合`pixi.js` `2D` 渲染库开发小游戏是值得关注的,在开发体验、开发效率方面表现不错,特别是对习惯`React`视图开发模式的开发者,能够节省大量的熟悉原生`pixi.js`开发方式的时间成本。此外,借助微信小游戏与H5平台通用的canvas API实现,通过适配器去抹平平台部分API的差异,实现了一次开发,分发多个平台,极大的降低了多平台发布的开发成本。因此,这一套技术栈对于有类似需求的开发者值得去尝试和体验一番。