一个名为”大力出奇迹“的增长活动,于2023年4月26日在哔哩哔哩app上悄然发布。活动的其中一个核心玩法是:用户可以通过玩一个小游戏,来获取活动代币(其名为”大力币“)。在这个游戏中,用户在8秒内点击一个按钮,每多点击10次,就能多获取一定数量的代币。作为活动的前端部分的主要开发人员,我将把这个游戏拆分成3个主要部分:”倒计时“、”游戏主体“、”撒金币“和”额外赠送机会“等其他动效,并对其技术方案和实现细节进行详细地介绍。
一、倒计时
在用户点开游戏弹窗以后,屏幕上会显示时长为4秒的”倒计时“动画。用户可以通过这段缓冲时间,观察整个游戏界面,并做好疯狂点击的准备!
【图片 —— 倒计时】
1.1 技术方案
”倒计时“动画总共涉及到4张图片。在1秒钟内,我们需要调整图片的缩放和透明度。而在上一秒与下一秒之间,我们需要按序切换上一张图片和下一张图片的显示。经过这样的分析,我们的核心诉求就明确为:按照一条时间轴,调整4张图片的css样式。
这里我选择使用gsap。gsap是一个简单好用的动画库,它最大的优点在于,它提供了时间轴对象(timeline),可以按照一个时间轴精确地操控多个对象的动画。其次是,它内置支持了更改对象的css属性。就凭这2个优点,gsap完美满足了我们的核心诉求!
gsap的官网地址是GreenSock。如果你有兴趣,可以去研究一下。
1.2 实现细节
凭借gsap强大的时间轴对象,我们就可以”简单粗暴“地实现”倒计时“动画了。
首先我们先为4张图片创建4个img标签,再在外层创建一个父元素作为界面的遮罩。
<div class="countdown-shadow">
<img id="countdown3" :src="countdown3" />
<img id="countdown2" :src="countdown2" />
<img id="countdown1" :src="countdown1" />
<img id="countdown4" :src="countdown4" />
</div>
然后,我们可以使用gsap的时间轴对象(timeline),依次操控图片的display、scale和opacity属性。
我们先让第一张图片显示出来,可以调用timeline的set方法来设置图片的起始属性。
const tl = gsap.timeline()
this.tl = tl
tl.set('.countdown-shadow', { display: 'flex' }) .set('#countdown3', { display: 'block', opacity: 1 })
我们再让这张图片在1秒内,scale属性由1变为0.7,opacity属性由1变为0,最后消失。
to('#countdown3', {
duration: 1,
scale: 0.7,
opacity: 0,
display: 'none'
})
紧接着,我们让下一张图片在前0.5秒内,scale属性由0.7变为1,opacity属性由0变为1。再在后0.5秒内,scale属性由1变为0.7,opacity属性由1变为0。最后消失。
.set('#countdown2', {
scale: 0.7,
opacity: 0,
display: 'block'
})
.to('#countdown2', {
duration: 0.5,
scale: 1,
opacity: 1
})
.to('#countdown2', {
duration: 0.5,
scale: 0.7,
opacity: 0,
display: 'none'
})
后面的图片的显示逻辑以此类推,这里就不多做赘述了。我们看到,gsap的timeline对象提供了set和to这两个非常好用地、可以精确操控对象动画和属性的方法,并可以链式调用。这种实现方式是偏过程式的编程方式,非常符合我们在现实中对”倒计时“这种动画的认知。
二、游戏主体
在倒计时结束后,游戏正式开始,整个游戏主体暴露在用户面前。用户可以点击界面下方的按钮,使界面上方的角色(其名为”小电视“)做出按压的动作。用户每点击1次,界面中间的进度条就会前进一格。用户每点击10次,进度条就会清空,同时用户会获得一定数量的大力币,当前获得的大力币数量会显示在界面偏下方的屏幕上。用户点击的频率越快,小电视按压的频率也就越快。
当然这个游戏也有2个限制条件:时间限制为8秒,用户点击次数的上限为150次。当时间限制达成时,游戏进入结束状态,对用户获取的大力币进行结算,并为用户提供了2种选择:再玩一次(如果还有游戏次数)和退出游戏。
【图片 —— 游戏主体】
2.1 技术方案
在最早的需求阶段中,界面中只有小电视和点击按钮两个部分,所以我们的第一版技术方案是使用第三方动画库frame-animation(https://www.npmjs.com/package/frame-animation),为小电视做一个帧动画。关于”用户点击的频率越快,小电视按压的频率也就越快“这个需求,我们为小电视设置了4个速度档位。当用户的点击频率达到某个档位时,我们将上一个动画对象销毁掉,然后重新创建一个播放速率不同的动画对象。
这种技术方案的弊端有2个:第一是动画在切换速度档位的时候,会有明显的卡顿感。第二是扩展性较差,如果需求扩展,界面上的动画变得更复杂,这个技术方案就不能很好地满足需求了。
在第二版技术方案中,我提出用gsap 雪碧图的方式,自己实现小电视的帧动画。实现原理很简单,就是用gsap逐渐改变雪碧图的background-position属性。这种方案的优势有2个:第一是gsap对动画进行了性能优化,而且支持在两次变化的衔接处进行缓动效果(easing),所以解决了第一版方案中,切换速度档位时有卡顿感的弊端。第二是因为是自己实现动画,所以扩展性较好,可以对动画实现自定义的改动和优化。
然而这种技术方案也有弊端:第一是gsap的本质是更改对象的css属性,只能满足简单的帧动画和位移动画,一旦动画对象变多,我们需要编写成倍的代码来实现动画本身(我们还没考虑动画对象之间还要有交互逻辑呢!)。
在第三版、也是最终版的技术方案中,我选择了pixijs。(距官网所说,)pixijs是一个基于webGL的2D渲染引擎,其实就是基于webGL封装了一系列简单易懂的API,让我们能快速搭建一个复杂的2D动画方案。
pixijs的官网地址是PixiJS: https://pixijs.io/guides/basics/what-pixijs-is.html。如果你有兴趣,可以去研究一下。
使用pixijs的最大的2个优势是:第一,pixijs的API大大简化了实现动画的代码。第二,pixijs充分利用了GPU,(天哪,我们终于想到要利用GPU了!)使复杂动画的性能有了巨大的提升。
这次我们使用的是pixijs的v7版本,也是其最新版本。我一开始的想法是,要做第一个吃螃蟹的人!要勇于探索最新的技术!的确,v7版本优化了一些API,比如(之前臭不可闻的)Loader类,也帮助我们实现了一些v6版本实现不了的小功能,比如v7将Sprite对象的currentFrame属性从只读的变成可写的,让我们实现了完全可控的进度条动画。但是,v7版本相较于v6也有了破坏性的改动,比如去掉了polyfill和对老旧的浏览器的支持,这一点在之后对我们造成了很大的困扰。
同时,我们还需要一个事件库,实现一个沟通vue实例和pixijs对象的事件总线(eventBus)。我选择了mitt,原因无他,惟轻量尔。
mitt的npm地址是https://www.npmjs.com/package/mitt。如果你有兴趣,可以去研究一下。
以上3种技术方案的优点和缺点,我总结成了如下的一张表格。
技术方案 | 优点 | 缺点 |
frame-animation | 实现最简单 | 切换速度档位时,有卡顿 |
gsap 雪碧图 | 实现较简单,扩展性较好 切换速度档位时,没有卡顿 | 可实现的动画类型和动画对象较少,不支持复杂的动画方案 |
pixijs | 性能最好 扩展性最好 支持复杂的动画类型和多个动画对象同屏 | 实现最复杂 v7有兼容性问题 |
【表格 —— 游戏主体技术方案对比】
2.2 实现细节
在介绍游戏主体的实现细节时,我将把主要笔墨花在介绍如何构建pixijs的主要类,然后分散地介绍我遇到的一些问题和解决方案。
2.2.1 主要类的构建
【图片 —— 主要类的结构】
2.2.1.1 Application
首先我们要构建的,是pixijs中最重要的一个类:Application。Application类实例是沟通canvas和pixijs其他类对象的重要桥梁,你只需要在入参的view属性里传入canvas实例,并自定义一些其他参数就行。
import { Application } from 'pixi.js'
const canvas = document.getElementById('game')
const width = window.screen.width
const height = (667 * width) / 375
// pixi Application
const application = new Application({
width,
height,
resolution: window.devicePixelRatio,
view: canvas,
backgroundAlpha: 0
})
值得一提的是,如果你想让背景变成透明的,可以传入backgroundAlpha: 0这个属性,至少这在v7是可行的。
2.2.1.2 资源加载
然后,我们需要加载一些资源,比如图片、字体等。
引入多张图片的最佳实践肯定是使用import语句,这里我们现在另一个文件里列出所有要用的图片,然后导出。
import body1 from '@/assets/pixi/body/body1.png'
import body2 from '@/assets/pixi/body/body2.png'
import body3 from '@/assets/pixi/body/body3.png'
import body4 from '@/assets/pixi/body/body4.png'
import body5 from '@/assets/pixi/body/body5.png'
import body6 from '@/assets/pixi/body/body6.png'
import body7 from '@/assets/pixi/body/body7.png'
import body8 from '@/assets/pixi/body/body8.png'
export const resources = {
body1,
body2,
body3,
body4,
body5,
body6,
body7,
body8
}
为了使用导出的图片资源,我们要使用pixijs的另一个类:Assets。在v7版本中,Assets类替换了v6版本的Loader类,用法更加简单:我们只需要将图片一个个add到Assets类中,然后调用load方法进行加载。
// 统一加载图片资源
load(resources) {
const resourceKeys = []
Object.keys(resources).forEach(key => {
const url = resources[key]
Assets.add(key, url, { crossOrigin: 'anonymous' })
resourceKeys.push(key)
})
return Assets.load(resourceKeys)
}
add有两个主要入参:key是你为图片资源的命名,后续你可以用这个key来引用这个图片;url是这个图片实际的路径,它最好是绝对路径。第三个入参是可选项,因为v6版本的Loader有图片跨域问题,加上crossOrigin: 'anonymous'可以解决该问题,我只是把这个选项沿用到了v7的Assets上,是否必要没有作验证。
为什么我说url最好是绝对路径?因为我在将项目发布到线上以后,图片路径与构建配置的publicPath合并以后出现了问题。如果合并以后的路径没有协议(http/https),Assets仍然会将其视作一个相对路径,并在前面拼接上页面的path。所以你必须在url前面手动拼上完整的路径前缀。
/**
* 对Assets加载的资源url做特殊处理
* 对转成base64的url,把base64的部分截出来作为url
* 对普通url,在前面加上https:,变成绝对路径
*/
const fixAssetsUrl = url => {
const base64Reg = /data:image\/png;base64,.*/g
const matchRes = url.match(base64Reg)
// 匹配base64的部分
if (matchRes) {
return matchRes[0]
}
// 普通的url
const prefix = process.env.NODE_ENV === 'production' ? 'https:' : ''
return prefix url
}
值得一提的是,Assets类会将base64格式的路径视为绝对路径。
如果你有用到特殊字体,为了保证字体在pixijs对象渲染之前被加载完成,你必须手动加载字体文件。这里我使用了第三方库fontfaceobserver,这也是pixijs官方推荐的加载字体的方案。(https://pixijs.io/guides/basics/text.html - Loading and Using Fonts
import FontFaceObserver from 'fontfaceobserver'
function loadFont(fontFamilyName, timeout = 10000) {
const fontOb = new FontFaceObserver(fontFamilyName, {})
return fontOb.load(null, timeout)
}
Promise.all([
this.loadFont('Alibaba PuHuiTi Regular'),
this.loadFont('Alibaba PuHuiTi'),
this.loadFont('DINCond-Black'),
this.loadFont('REEJI-TaikoMagicGB')
])
值得一提的是,加载字体的load方法,默认的超时时间是3秒,这个时间在实际生产环境往往是不够的。为了更好的用户体验,我们可以将超时时间设置得长一点,这里设置了10秒。
2.2.1.3 Container & Sprite
接下来,我们就可以生成动画对象了。
如果你需要渲染一个静态对象,pixijs的Sprite类就可以满足你的要求。之前我们使用了Assets类的load方法加载了图片资源,这个方法会返回一个Promise对象,resolve出的是一个加载完的纹理对象(assets)。我们通过图片的key来引用assets中对应的纹理,并传入的Sprite类中,来创建一个静态”精灵“。
Assets.load(resourceKeys).then(assets => { ... })
...
const sprite = new Sprite(assets.body1)
sprite.x = 0
sprite.y = 0
sprite.scale.set(0.8)
如果你需要渲染一个动画对象,pixijs的AnimatedSprite对象可以满足你的要求。在传入纹理时有2种选项。
第一种选项:如果你使用的是由一张张小图片拼起来的雪碧图,你手上会有一张雪碧图和一份描述雪碧图上各个小图的位置信息的JSON文件。(我强烈推荐你使用TexturePacker这个雪碧图制作软件,来尝试制作属于自己的雪碧图,顺便你会了解我这里说的JSON文件里,大概是哪些内容。)接着,你可以使用pixijs的Spritesheet类,把雪碧图纹理(你仍然需要把雪碧图add到Assets里)和JSON文件(通过import引入)传入Spritesheet类中。最后通过调用parse方法来获得一个Spritesheet实例。
import { Spritesheet } from 'pixi.js'
import bodyJson from '@/assets/pixi/body/body.json'
const sheet = new Spritesheet(assets.body, bodyJson)
const spritesheet = await sheet.parse()
通过parse方法获取的spritesheet实例中,有一个animations属性,你可以使用它来创建一个动画”精灵“。
import { AnimatedSprite } from 'pixi.js'
const sprite = new AnimatedSprite(spritesheet.animations)
第二种选项:你可以把一个纹理数组直接传入AnimatedSprite类中,可以直接生成由这些纹理组成的一个动画”精灵“。
import { AnimatedSprite } from 'pixi.js'
const textureArr = []
for (let i = 1; i <= 8; i ) {
const texture = assets[`body${i}`]
textureArr.push(texture)
}
const sprite = new AnimatedSprite(textureArr)
默认动画精灵只会播放一次动画,如果你要让它循环播放动画,就把它的loop属性设置为true。你也可以设置它的animationSpeed属性,控制动画的播放倍速(这个属性为小电视的改变速度奠定了基础)。AnimatedSprite类继承自Sprite类,其他属性你可以参考Sprite类实例进行设置。
sprite.x = 0
sprite.y = 0
sprite.scale.set(scaleRatio) // 素材是3倍图,必须等比缩小
sprite.loop = true
sprite.animationSpeed = 0.8
如果把Sprite类实例比作HTML的img标签,那么Container类实例就是包裹这些img标签的父元素div标签。Container类,顾名思义,可以作为容器承载pixijs常用的图形和精灵实例,使代码层次更加分明。在实践中,我们往往会自定义一个类,继承Container类,然后在里面执行图形和精灵的创建逻辑。
import { Container, Sprite } from 'pixi.js'
class Body extends Container {
constructor(app, assets) {
super()
this.app = app
this.init(assets)
}
init(assets) {
const sprite = new Sprite(assets.body)
sprite.x = 0
sprite.y = 0
this.addChild(sprite)
}
}
值得注意的是,你需要手动调用Container类实例的addChild,将图形和精灵加入到容器中,否则它们不会渲染到屏幕上!
2.2.1.4 自定义Stage类
随着我们创建的自定义Container类越来越多,我们迫切地需要一个自定义类,来承载这些Container类的创建、销毁和执行实例方法的逻辑。因此,自定义Stage类应运而生。
除了自定义Container类的逻辑,我们也可以将eventBus的逻辑也塞到这个类里面。Stage类就像一个广阔的舞台,默默见证着无数图形和精灵的精彩演绎。
import emitter from './utils/mitt'
import Body from './objects/Body'
import Progress from './objects/Progress'
import BtnClick from './objects/BtnClick'
class Stage extends Container {
constructor(app, assets) {
super()
// 创建自定义Container类实例
const body = new Body(app, assets)
const progress = new Progress(app, assets)
const btnClick = new BtnClick(app, assets)
...
this.addChild(body, progress, btnClick)
...
// eventBus 接收事件
// 总动画开始
emitter.on('Stage/start', () => {
body.play()
})
// Body改变速度
emitter.on('Body/changeSpeed', ratio => {
body.changeSpeed(ratio)
})
...
}
}
因为Stage类的”孩子”同样需要在屏幕上渲染,所以Stage类也必须继承自Container类。
2.2.1.5 自定义GameApplication类
还有最后2块逻辑,我们还放任了它们自由,它们分别是Application的创建和资源加载。我们可以把这些逻辑全塞进一个自定义类里,这个类就是GameApplication。
import { Application, Assets, utils } from 'pixi.js'
import FontFaceObserver from 'fontfaceobserver'
import { resources } from './resources'
import Stage from './Stage'
import emitter from './utils/mitt'
/**
* 对Assets加载的资源url做特殊处理
* 对转成base64的url,把base64的部分截出来作为url
* 对普通url,在前面加上https:,变成绝对路径
*/
const fixAssetsUrl = url => {
const base64Reg = /data:image\/png;base64,.*/g
const matchRes = url.match(base64Reg)
// 匹配base64的部分
if (matchRes) {
return matchRes[0]
}
// 普通的url
const prefix = process.env.NODE_ENV === 'production' ? 'https:' : ''
return prefix url
}
class GameApplication {
constructor(options) {
/**
* https://pixijs.io/guides/basics/text.html
* 用fontfaceobsever手动加载字体文件
* */
Promise.all([
this.loadFont('Alibaba PuHuiTi Regular'),
this.loadFont('Alibaba PuHuiTi'),
this.loadFont('DINCond-Black'),
this.loadFont('REEJI-TaikoMagicGB')
])
.catch(err => {
console.error(err)
})
.finally(() => {
this.app = new Application(options)
this.load(resources).then(assets => {
this.init(assets)
})
})
}
loadFont(fontFamilyName, timeout = 10000) {
const fontOb = new FontFaceObserver(fontFamilyName, {})
return fontOb.load(null, timeout)
}
// 统一加载图片资源
load(resources) {
const resourceKeys = []
Object.keys(resources).forEach(key => {
const url = resources[key]
const fixedUrl = fixAssetsUrl(url)
Assets.add(key, fixedUrl, { crossOrigin: 'anonymous' })
resourceKeys.push(key)
})
return Assets.load(resourceKeys)
}
init(assets) {
const stage = new Stage(this.app, assets)
this.stage = stage
this.app.stage.addChild(stage)
emitter.emit('game/ready')
}
destroy() {
Assets.reset()
this.stage.destroy()
this.stage = null
utils.clearTextureCache()
this.app.destroy(true)
this.app = null
}
}
export default GameApplication
可以看到,GameApplication类的入参是用于创建Application类实例的选项(options),它同时包含了创建Application类、加载字体、加载图片资源和销毁资源的逻辑。
注意有一步非常关键,你必须把自定义Stage类实例,添加到Application类实例的stage属性中。你可以理解为Application类实例的stage(app.stage)是最大的、内置的一个Container。这样,你就把所有可渲染的对象全部挂载到了Application类实例中,可以进行渲染了!
2.2.1.6 资源销毁
我们还剩一个容易忽视的小尾巴没有介绍,那就是如何销毁资源。我们创建了如此多的类,加载了很多图片和字体资源,这些资源在游戏结束后必须被销毁!
在自定义Stage类里,你只需要注销eventBus对所有事件的监听,防止其重复监听并触发事件处理函数。
destroy() {
emitter.off('Stage/start')
emitter.off('Stage/reset')
emitter.off('Body/changeSpeed')
emitter.off('Progress/playOne')
emitter.off('Battery/update')
emitter.off('BtnClick/updateCount')
emitter.off('Stage/max')
emitter.off('BlastCount/update')
emitter.off('Stage/end')
super.destroy(true)
}
而在自定义GameApplication类中,我总结出了一套销毁资源的最佳实践。
destroy() {
Assets.reset()
this.stage.destroy()
this.stage = null
utils.clearTextureCache()
this.app.destroy(true)
this.app = null
}
Assets.reset():用于清空Assets.load()加载的所有资源的key。(如果你的控制台里有很多warning: [ Resolver ] already has key: xxx overwriting 。这一条是必须的。)
this.stage.destroy():调用自定义Stage类实例的销毁函数。
this.stage = null:消除对Stage类实例的持有。(销毁持有后,过一会儿,pixijs的gc会自动执行Stage类和它持有的所有pixi的children的销毁函数。js的gc会销毁Stage类中所有持有的js对象)
this.app.destroy(true):调用Application类实例的销毁函数。传入true,以调用所有纹理和”孩子“的销毁函数。
this.app = null:消除对Application类实例的持有。
2.2.1.7 该节总结
2.2.1 这一小节中,我花了大量笔墨,详细介绍了游戏中主要类的构建思路和实现细节。我从头到尾介绍了pixijs原生的Application、Assets、Container和Sprite的使用方法,然后再从尾到头介绍了这些逻辑可以封装到2个自定义类:Stage和GameApplication,最后介绍了pixijs的资源销毁的最佳实践。
在下几个小节中,我将针对我遇到的主要问题和解决方案,进行简要地介绍。
2.2.2 小电视主体渲染问题
小电视人物 底座作为一个整体,占据了游戏界面的主要空间。一旦这个主体资源的位置在渲染时发生偏移,或者大小不适配屏幕,问题会变得非常显眼。因此,小电视主体资源必须精确地定位在游戏界面上,而且其宽高要适应不同屏幕宽度的机型。
针对这些问题,我们向设计提出,小电视主体的图片素材,必须按照375 * 667(或其倍数)的标准尺寸提供给我们,相比原尺寸多出的地方,用透明背景来填充。
在编写代码时,我们创建完小电视的动画”精灵“以后,首先计算了当前屏幕宽度相对于375像素的倍数,再按这个倍数对图片纹理进行缩放,以适应不同屏幕宽度的机型。
在开发的过程中我们遇到了一个小插曲:我们在电脑浏览器上开发的时候,整个游戏能正常地渲染,然而当我们在移动端运行项目的时候,整个游戏界面就黑屏了!此时,我的脑海里产生了2种可能的原因。
第一种可能的原因:pixijs v7版本不兼容移动端,或者某个部分在移动端有问题。我用pixijs v7新写了一个demo,用了一些简单的图片素材,结果它在我的手机上是可以正常渲染的。好吧,pixijs v7版本的确在部分机型上有兼容性问题,但这是另一个问题,并不是这个问题的原因。
第二种可能的原因:Assets在加载图片资源的时候有问题,导致图片纹理全部丢失或损坏了。我使用VConsole等调试工具,在移动端查看了是否发出了图片资源的请求,结果是确实请求了图片资源。因为在电脑浏览器上可以渲染,我认为这大概率也不是这个问题的原因。
正在我一筹莫展,像无头苍蝇一样逛谷歌和各种论坛的时候,一个词出现在了我的视野中:MAX_TEXTURE_SIZE。WebGL对纹理的最大尺寸进行了限制,在电脑的浏览器上一般是4096*4096,而在移动端则一般是2048*2048。这个限制可能考虑到了较大的纹理尺寸对内存、GPU运算速度和显示效果的负面影响。(简单来理解,纹理越大,塞到GL Buffer的难度越大,GPU运算得越慢,显示得越慢越粗糙)我们在一开始渲染小电视主体的时候,使用的雪碧图:每张图片是3倍图,其尺寸也就是1125*2001,一共8张图片组成一张雪碧图。很明显,这张雪碧图的尺寸远远超出了MAX_TEXTURE_SIZE。
之后,我们选择将这张雪碧图重新拆成一张张小的图片,通过纹理数组的方式传入到AnimatedSprite类里,成功解决了渲染黑屏的问题。
2.2.3 其他静态对象的定位问题
在考虑其他静态对象的渲染方案时,就不能直接套用小电视主体的方案了。一方面,如果这些小的静态对象也采用375*667的尺寸的图片资源,这个项目的图片所占用的体积就会非常大,浪费带宽。另一方面,这些静态对象对定位的精度要求较低,即使偏了一点,用户大概率也能接受。因此,我选择保持这些静态对象的图片的原尺寸,手动计算其位置坐标。
在计算坐标之前,我们需要了解一个前提条件:pixijs的Sprite对象的中心点默认在左上角,碰巧的是,页面渲染的原点也在左上角。因此当我们因为屏幕宽度不同而对静态对象进行缩放时,与我们对静态对象进行位移以确定其在页面上的位置时,二种操作的原点是一致的。
基于这个前提条件,我们可以先对静态对象进行缩放操作,以适配非375px的屏幕宽度的机型,然后再对静态对象进行位移操作,位移的坐标是基于375px的屏幕宽度下。注意,先缩放后位移。
const btn = new Sprite(assets.btnYellow)
const scaleRatio = this.app.screen.width / 375 / 3 // 素材是3倍图
btn.scale.set(scaleRatio)
btn.x = 28
btn.y = 570
2.2.4 pixijs v7的兼容性问题
pixijs v7最重大的改动,就是删除了polyfill,取消了对旧版本浏览器的支持。这个问题直到测试的时候才被发现。在有限的测试机型中,我们遇到的兼容性问题如下:
1. ios11不支持扩展运算符(...)。可以使用babel插件:@babel/plugin-proposal-object-rest-spread
2. 安卓7不支持globalThis。可以使用babel插件:babel-plugin-transform-globalthis
3. 低版本浏览器不支持array.prototype.flat和array.prototype.flatMap。这个没有找到很优雅的解决方案。我是直接在入口文件里引入了core-js里的2个js文件。
import 'core-js/modules/es.array.flat-map'
import 'core-js/modules/es.array.flat'
三、撒金币等其他动效
在游戏主体之外,仍有一些大大小小的动效,需要不一样的技术方案去实现。概括来说,我们主要尝试了PAG、SVGA和视频这3种方案。首先,我将简单介绍这3种方案是什么,然后再通过2个实际案例来说明,我们是如何从这3种方案中选择,并加以实现的。
3.1 技术方案
3.1.1 PAG
PAG(Portable Animated Graphics)是由腾讯自主研发的一套完整的动效工作流解决方案。设计师在AE中制作动效以后,可以通过PAG Exporter导出.pag格式的素材文件,并通过其SDK将素材文件应用于移动端、桌面端、Web端和小程序端等不同平台上。
此外,官网还提供了PAGViewer,你可以通过它来预览pag文件的效果。
PAG官网是https://pag.art/。
3.1.2 SVGA
SVGA是由YY团队开发的一种动画格式,可以兼容iOS、Android、Flutter和Web多个平台。虽然名字里包含SVG,但SVGA不仅可以兼容矢量图形,还可以兼容位图。
SVGA的官网是https://svga.io/。
我们常用的是官网提供的web端SVGAPlayer库:svgaplayerweb,其github仓库地址为https://github.com/svga/SVGAPlayer-Web,其npm地址为https://www.npmjs.com/package/svgaplayerweb。
3.1.3 视频
关于动画,我们常用的视频文件格式是MP4。MP4是一种多媒体文件格式,常用于储存视频和音频数据。
3.2 具体案例
3.2.1 撒金币动效
针对撒金币等这些小动效,我们首先尝试了PAG的方案。
但是PAG方案在实现时出现了2个明显的问题:第一,一个PAG文件就要占用了一个canvas元素。如果以一个页面需要展示多个PAG实现的动画,就需要创建多个canvas元素。这会对内存造成很大的开销,也并非最佳实践。第二,PAG在移动端的性能较差。实际使用时,动画视频出现了末尾掉帧的情况(动画停在了最后一帧)。官方的一篇“兼容性情况”的文章也提到了这一个问题:https://pag.art/docs/web-sdk/compatibility.html。
【图片 —— PAG动画在移动端“末尾掉帧”问题】
抛弃了PAG以后,我们转而考虑SVGA。SVGA的兼容性和性能都比较好,但是文件体积会比PAG大很多。好在这些小动效的文件体积仍在可以接受的范围内(1-2M)。详细的文件体积的对比,我列在下方。
动画 | PAG文件体积 | SVGA文件体积 |
撒金币动画 ,时长00:01 | 212K | 1M |
“再加2次游戏机会”动画 ,时长00:10 | 489K | 2.3M |
【表格 —— 撒金币动效PAG/SVGA文件体积对比】
3.2.2 KV动效
针对KV动效,我们的技术方案也经历了从PAG到SVGA的选择转变。然而,KV的SVGA文件体积实在太大了。KV分为休息状态和普通状态2种动画,文件体积分别是10M(20帧,1125 *1500)和25M(50帧,图片尺寸1125*1500),会对带宽造成巨大的开销。
没办法,我们只能将目光投向MP4方案,原因有2条:第一,KV动画只需要播放,不需要多余的处理逻辑。第二,MP4文件的体积可以被压缩得较小。详细的文件体积的对比,我列在下方。
动画 | PAG文件体积 | SVGA文件体积 | MP4文件体积 |
KV休息态 ,时长00:04 | 543K | 10M (20帧,1125 * 1500) | 714K |
KV普通态 ,时长00:08 | 1.3M | 25M (50帧,1125 * 1500) | 2M |
【表格 —— KV动效PAG/SVGA/MP4文件体积对比】
在使用MP4文件时,我们第一时间想到的就是在html模版里直接使用video标签,并将src设置成MP4文件的链接。如果我们需要让视频自动播放,我们通常会让视频静音起播。然而这种写法在ios是不行的,我们必须将这段创建video标签的代码通过v-html指令,动态插入到html模版中。
<template>
<div class="key-vision-video-wp" v-html="getVideoHtml()"></div>
</template>
<script>
export default {
methods: {
getVideoHtml() {
const video_url = this.videoType === 'normal' ? VIDEO_NORMAL : VIDEO_RESET
// html写法可以兼容ios自动播放,oncanplay为了处理视频加载时候有播放按钮的情况
return `<video
id="video"
preload="auto"
muted="muted"
autoplay="autoplay"
playsinline=""
webkit-playsinline=""
loop="loop"
style="opacity:0"
oncanplay="style.opacity=1;window.daliOnVideoCanPlay()"
class="video show-video"
<source src="${video_url}" type="video/mp4">
</video>`
}
}
}
<script>
这种写法能解决视频在大多数ios机型上无法自动播放的问题。
针对以上的技术方案的优点和缺点,我总结了如下的一张表格。
技术方案 | 优点 | 缺点 |
PAG | 文件体积最小 | 移动端性能和兼容性较差 |
SVGA | 性能和兼容性最好 | 文件体积较大 |
MP4 | 文件体积中等 性能和兼容性中等 | ios机型有自动播放问题 低版本安卓机型会显示播放按钮图标 |
【表格 —— PAG/SVGA/MP4技术方案对比】
四、总结
本篇文章介绍了大力出奇迹活动的小游戏,通过划分3个主要部分,并介绍这些动效的技术方案和实现细节,还原了我们对小游戏技术方案的探索过程,希望通过这个案例的经验和实现细节给读者一些帮助。后续将会有一篇关于动效方案选型建议的文章,读者们敬请期待。
作者:王栋辉 - 哔哩哔哩开发工程师
来源:微信公众号:哔哩哔哩技术
出处:https://mp.weixin.qq.com/s/tGAvpexYerd8Ff7ddzji5w
Copyright © 2024 妖气游戏网 www.17u1u.com All Rights Reserved