|
作者简介:唐文城,来自抖音互动技术团队,21 年毕业后持续探索互动技术,参与过若干个抖音活动业务,国庆项目互动玩法与动效核心开发者,喜欢做“可以看见”的事情。前言经过若干个月的点滴积累,我有幸参与到抖音国庆活动的开发,这是我第一次完整参与大型活动项目的开发,它是全员关注的一个重点项目,致力于让用户领略美好中国,指导用户在抖音中搜索与获取旅行攻略和出游信息。在本项目中使用的技术栈为Lynx + Cocos的组合。Lynx是字节跳动自研的一个跨端框架,首屏直出的方案使其具有较短的首屏时间,能够带来可观的业务收益。我负责其中的互动玩法侧部分,使用Cocos进行开发,Lynx提供一个 canvas 作为Cocos的容器,Lynx的 UI 线程与 JS 线程是隔离的,其与Cocos运行在同一个 JS 线程上。考虑到许多同学可能没有接触过Cocos,本文在前半部分首先对Cocos的基本概念进行介绍,使大家有一个初步印象,接着简要过一遍官方的小游戏 Demo 代码,了解一个简单的小游戏是如何跑起来的,后半部分则是主题:国庆项目中的一些经验之谈。Cocos 简介Cocos 产品包括 Cocos Creator、2d-x 等一系列产品,本文所讲的 Cocos 指 Cocos Creator。Cocos Creator 是一个完整的游戏开发解决方案,它包括编辑器与游戏引擎。由于图形化的编辑器、优良的系统设计以及完善的文档,Cocos 上手非常容易,对新人十分友好。Cocos 基本概念节点与组件Cocos 以组件式开发为核心,这种架构方式即实体组件系统(ECS)。ECS 是一种流行的结构思想,遵循组合优于继承的原则。我对它的理解是:通过节点与组件的组合来构建实体,达到目的,这与继承的方式有所区别。通过继承的方式来造一辆 Bus:首先我们拥有一个基类:Vehicle,它有轮子、发动机、长宽高、载客量等属性,并有一个开门方法。然后我们定义一个Vechicle的子类Bus,明确有 6 个轮子,能乘坐 30 人,并重写开门方法(需要司机通过按钮控制门的开关而不是乘客用手拉门),这样便有了一个 Bus 类。问题来了,如果除了Bus之外还要实现其他类型的车,甚至是火车、动车呢?它们的开门方法都是司机来控制,因此具有一致的开门方法,为了能够复用这个方法,可能需要再定义一个Vehicle的子类,它实现了司机控制开门的方法,接着Bus、火车、动车再去继承这个类。ECS 的思想则是组合优于继承,根据它的思想,要造一辆Bus,首先我们在世界中添加一个空的实体,给它取名为Bus,这样我们便知道现在这个看不见摸不着的实体未来将会是一辆Bus。接着给它添加上Vehicle组件,这个组件是上帝给我们的,它将给这个实体提供运动、载人的基本能力,我们在这个Vehicle组件中设定轮子个数为 6,载人量 30。现在要解决开门的问题了,区别于继承的方式,我们要通过组件组合的方式去解决未来造不同类型汽车开关门方法不同的问题。ECS 的方式是准备一系列门组件,有电控门、推拉门、滑轨门等等,对于现在要造的Bus,装上电控门组件即可,如果未来造别的车需要不同形式的门,只需要装上不同类型的门组件。那么这种思想在 Cocos 中是如何体现的?在 Cocos 中,节点(Node)是承载组件的实体,我们通过将具有各种功能的 组件(Component)挂载到节点上,来让节点具有各式各样的表现和功能。接下来以一个标签节点为例,它具有显示文字的能力。首先创建一个空节点(当然也可直接创建一个标签节点,殊途同归),我们就拥有了一个还不具备任何能力的实体。我们使用标签节点是因为我们需要显示文字的能力,因此我们为该节点添加 Label 组件,它提供了显示文字所需要的能力,包括字体大小、种类、加粗、斜体等。为了让该标签在任何不同尺寸比例的屏幕上显示时都固定在屏幕底部,我们需要类似 css 中 position 的能力,Widget 组件提供了对应的能力。点击添加组件,选择 UI 组件中的 Widget 组件,勾选 Bottom,此时该标签节点便拥有了自动对齐的能力。如果这个标签还需要添加淡入淡出的效果呢?可以添加一个 Animation 组件,它提供了使用动画编辑器来制作动画的能力。如何在代码中控制这个标签的文本内容?首先新建一个 ts 文件,在 Cocos 中,ts/js 文件属于用户脚本组件,并编写以下代码,其功能是每秒刷新显示的时间。const{ccclass,property}=cc._decorator;@ccclassexportdefaultclassLabelDemoextendscc.Component{start(){//获取标签节点的标签组件constlabelComponent=this.node.getComponent(cc.Label);//设定一个定时器,每秒修改显示的内容this.schedule(()=>{labelComponent.string=`当前时间{Date()}`;},1);}}由于代码也是组件,我们可以将它添加到节点上去,这样该节点就拥有了显示时间的能力。坐标系统Cocos 中使用是笛卡尔坐标系,与 WebGl 相同。在 Cocos 中有一个很基础的概念:锚点。锚点的位置代表整个节点的位置,锚点不仅影响自身以及子节点的定位,还会影响缩放和旋转。在 Web 开发中一般没有锚点的概念,用一个不太准确的例子类比一下,在 css 中设置定位为 fixed,设定 left、top 的大小时,这个元素的锚点就是自身左上角。在 Cocos 中锚点可以处于节点自身约束框中的任意位置。实际开发中,为了计算或定位的方便应该将锚点放置在一个合适的位置,例如人物的脚底。Web 开发中常用屏幕坐标系,与 Cocos 的笛卡尔右手系不同。有时一些需求要求物体移动到屏幕上的某个点,而给到的坐标是屏幕坐标系的,例如国庆项目中金币飞起至进度条红包中,而进度条是 lynx 元素。此时就需要进行坐标换算,好在换算比较简单,只需在纸上列出一个方程组即可得到换算公式。层级顺序与生命周期在节点树中,子节点永远显示在父节点之上,对于同级的节点,后面的节点会显示在前面的节点之上。可以通过修改节点的 zIndex 属性来控制其层级,但这仅限于同级节点之间。Cocos 为组件脚本提供了以下生命周期。onLoadstartupdatelateUpdateonDestroyonEnableonDisable其中最常用的是onLoad start update这三个生命周期。节点更新以深度优先遍历的顺序进行,因此不同节点的生命周期回调执行顺序总是父节点早于子节点,前面的兄弟节点早于后面。onLoad回调在节点首次激活时触发,该阶段保证了可以获取到场景中的其他节点以及关联的资源数据 ,因此如果要为该节点挂载预制节点,应该在该阶段进行,但需要注意的是,如果需要获取在遍历顺序之后的某些节点,而这些节点又是预制节点,将有可能无法获取到这些节点导致发生错误。start回调在组件首次激活时触发,start总是晚于onload。一般在本阶段对数据进行初始化。update回调在组件每帧渲染前执行,可以理解为由requestAnimationFrame驱动。游戏开发的一个关键点是在每一帧渲染前更新物体的行为、位置等,通常都放在该回调中。例如当玩家按下前进按钮时,应在每帧的回调中更新玩家的位置。回调函数参数是一个 number 类型的 dt,为上一帧与本帧之间的时间间隔,距离 = 时间 * 速度,这样即可让玩家在任何帧率下都保持恒定的速度前进,即使帧率有较大波动。吃小游戏 Demo 解析接下来我将简要讲解一下这个 Demo 是如何跑起来的,目的是通过这个简单的让未接触过 Cocos 的同学了解一下 Cocos 的代码组织方式和运行逻辑,有兴趣深入了解的同学可以跟着文档来写一写。吃小游戏试玩Cocos 官方文档先观察节点划分,这块可以在编辑器里看下每个节点的属性。能够直接看到的有背景、地面、玩家、分数节点,另外还有在代码中向场景挂载的星星节点。知道节点划分就能去感受游戏整体逻辑大概是怎么样的了。接下来看代码部分。以下代码与试玩版本有些许出入,官方为了便于新人上手,某些部分作了简化。Game.js组件挂载于Canvas节点下,onLoad时初始化数据并生成一个新的。//Game.jsonLoad:function(){//获取地平面的y轴坐标this.groundY=this.ground.y+this.ground.height/2;//初始化计时器this.timer=0;this.starDuration=0;//生成一个新的星星this.spawnNewStar();//初始化计分this.score=0;},生成新方法,instantiate 一个新的,并添加到 Canvas 节点下://Game.jsspawnNewStar:function(){//使用给定的模板在场景中生成一个新节点varnewStar=cc.instantiate(this.starPrefab);//将新增的节点添加到Canvas节点下面this.node.addChild(newStar);//为星星设置一个随机位置newStar.setPosition(this.getNewStarPosition());//在星星组件上暂存Game对象的引用newStar.getComponent('Star').game=this;//重置计时器,根据消失时间范围随机取一个值this.starDuration=this.minStarDuration+Math.random()*(this.maxStarDuration-this.minStarDuration);this.timer=0;},getNewStarPosition:function(){varrandX=0;//根据地平面位置和主角跳跃高度,随机得到一个星星的y坐标varrandY=this.groundY+Math.random()*this.player.getComponent('Player').jumpHeight+50;//根据屏幕宽度,随机得到一个星星x坐标varmaxX=this.node.width/2;randX=(Math.random()-0.5)*2*maxX;//返回星星坐标returncc.v2(randX,randY);},得分方法,当玩家碰到时调用,更新 score 的值并更新 UI 上的分数。gainScore:function(){this.score+=1;//更新scoreDisplayLabel的文字this.scoreDisplay.string='Score:'+this.score;//播放得分音效cc.audioEngine.playEffect(this.scoreAudio,false);},游戏结束方法,失败时调用,将重置场景以重新开始游戏。gameOver:function(){this.player.stopAllActions();//停止player节点的跳跃动作cc.director.loadScene('game');}关键点:每帧渲染前判断游戏是否失败。update:function(dt){//每帧更新计时器,超过限度还没有生成新的星星//就会调用游戏失败逻辑if(this.timer>this.starDuration){this.gameOver();this.enabled=false;//disablegameOverlogictoavoidloadscenerepeatedlyreturn;}this.timer+=dt;},接下来转到 Player.js。onLoad 时初始化速度为 0,挂载键盘输入监听onLoad:function(){//初始化跳跃动作varjumpAction=this.runJumpAction();cc.tween(this.node).then(jumpAction).start()//加速度方向开关this.accLeft=false;this.accRight=false;//主角当前水平方向速度this.xSpeed=0;//初始化键盘输入监听cc.systemEvent.on(cc.SystemEvent.EventType.KEY_DOWN,this.onKeyDown,this);cc.systemEvent.on(cc.SystemEvent.EventType.KEY_UP,this.onKeyUp,this);},runJumpAction方法使用了缓动动画,无限循环的上下移动,让玩家一直保持在跳跃状态。runJumpAction:function(){//跳跃上升varjumpUp=cc.tween().by(this.jumpDuration,{y:this.jumpHeight},{easing:'sineOut'});//下落varjumpDown=cc.tween().by(this.jumpDuration,{y:-this.jumpHeight},{easing:'sineIn'});//创建一个缓动vartween=cc.tween()//按jumpUp,jumpDown的顺序执行动作.sequence(jumpUp,jumpDown)//添加一个回调函数,在前面的动作都结束时调用我们定义的playJumpSound()方法.call(this.playJumpSound,this);//不断重复returncc.tween().repeatForever(tween);},处理键盘事件,响应向左/右移动的操作onKeyDown(event){//setaflagwhenkeypressedswitch(event.keyCode){casecc.macro.KEY.a:this.accLeft=true;break;casecc.macro.KEY.d:this.accRight=true;break;}},onKeyUp(event){//unsetaflagwhenkeyreleasedswitch(event.keyCode){casecc.macro.KEY.a:this.accLeft=false;break;casecc.macro.KEY.d:this.accRight=false;break;}},每帧渲染前根据当前加速度方向更新速度,并更新玩家位置。update:function(dt){//根据当前加速度方向每帧更新速度if(this.accLeft){this.xSpeed-=this.accel*dt;}elseif(this.accRight){this.xSpeed+=this.accel*dt;}//限制主角的速度不能超过最大值if(Math.abs(this.xSpeed)>this.maxMoveSpeed){//ifspeedreachlimit,usemaxspeedwithcurrentdirectionthis.xSpeed=this.maxMoveSpeed*this.xSpeed/Math.abs(this.xSpeed);}//根据当前速度更新主角的位置this.node.x+=this.xSpeed*dt;},接着看最后一个 js 文件:Star.js。通过 position 的 api 可直接计算玩家与本节点()之间的距离。getPlayerDistance:function(){//根据player节点位置判断距离varplayerPos=this.game.player.getPosition();//根据两点位置计算两点之间距离vardist=this.node.position.sub(playerPos).mag();returndist;},onPicked方法,收集到时调用,销毁当前并生成一颗新的,更新得分。onPicked:function(){//当星星被收集时,调用Game脚本中的接口,生成一个新的星星this.game.spawnNewStar();//调用Game脚本的得分方法this.game.gainScore();//然后销毁当前星星节点this.node.destroy();},最后是经典的 update 环节,每帧判断和主角之间的距离是否小于收集距离,同时每帧降低的透明度。update:function(dt){//每帧判断和主角之间的距离是否小于收集距离if(this.getPlayerDistance() 内存 => GPU 显存,虽然图片裁半后内存不减,但当禁用掉屏幕之外的背景节点时,该节点不再被渲染,其纹理资源也不需要存在于显存中了,对移动端来说不存在独立的显存,因此其体积的减小就会反应在内存占用上。考虑到有约 50% 的时间只显示一个半图,那么在背景图片内存方面就能节省 25 %。打卡点背景衔接若背景只是单调地无限循环,实现就会比较简单,但实际上玩家接近打卡点时需要过渡到打卡点专属背景,这就提高了整个背景循环逻辑的复杂度。我将背景循环抽象为三种状态,如图所示,该状态将以顺时针方向流转。打卡点过渡当玩家使用了道具卡或凭借双腿加毅力积累了足够的里程后,服务端判定用户到达了打卡点,玩家的状态变化便会体现在接口返回的数据中,此时背景的状态也会同步流转为 arriveScenery,当画面行进到背景图边缘时,发现状态已经改变了,就会激活打卡点相关的节点并调整坐标,使画面平稳过渡到打卡点。打卡点过渡打卡点过渡当然事情没有想象中这么顺利,前景和中景是以不同速度运动的,前景与中景都包含打卡点专用景色图。打卡点位于前景上,中景以前景 40% 的速度运动,如果没有特殊要求,前景与中景是没有关联的。但设计师随后提出要求,当玩家到达卡点时,中景也恰好落在打卡点范围内。此时如果为了维持开发进度,从研发增加的成本上来讲是可以不实现这个需求的,但秉承着追求极致的理念,我决定把这个需求盘下来。从游戏侧的角度来看,状态流转为 arrvieScenery 这个事件是随机时间发生的,发生时前景和中景的位置亦处于随机位置。此时前景和中景到打卡点的距离有近有远,我要做的是思考如何让前景维持原速度前进同时让中景打卡点范围也出现在屏幕上,其实关键思路的答案已经呼之欲出了,那就是调整中景运动速度同时控制近景与打卡点距离。若近景距离打卡点更近,则让近景增加一个循环,同时重新计算中景运动速度,若中景距离打卡点更近,则降低中景运动速度。为了防止视觉效果突兀,我将中景的运动速度上限限制在近景的 80%,且速度改变时增加一个线性的速度过渡效果。伪代码与实现代码如下,有兴趣的同学可以看看。设玩家距离打卡点【D玩】,中景距离打卡点路段第一屏【D景】if(按照当前速度前进,中景无法落在打卡点路段内) { if(D玩 * 0.8 > D景) { // 说明中景实在落后太多,无法到达 极端情况:近1.5屏,远5屏 => 近5.5屏,远5屏 玩家再多走一个循环,使得 D景 + 1.6屏 调快中景运动速度,使得中景落在打卡点路段最左侧 中景新速度 = D景 / (D玩 + 4屏) } else { // 极端情况:近景5.5屏,远景3屏 调快背景运动速度,使得中景落在打卡点路段最左侧 中景新速度 = D景 / D玩 }}code人物人物使用骨骼动画(Spine)实现,由设计师制作动画,开发时在代码层面调用相关 api 播放已制作好的动画使人物动起来,因此开发者并不需要关注动画的具体实现,而是关注在什么状态下切换至对应的动画,并使用 Mix 实现动作之间的平滑过渡。骨骼动画由用于绘制模型的蒙皮(Skin)以及用于控制动作的骨架组成,动画对骨架的运动方式进行描述,依附在骨架上的蒙皮跟随运动,形成动画效果。相比于常见的帧动画,骨骼动画显然需要更多 CPU 开销,但内存开销小,且能够在切换动作时计算出中间的过渡动作,这是帧动画做不到的。骨骼动画示意值得注意的是人物相关节点的划分(包括主体、光效、点击热区)也会对逻辑的实现造成影响,例如进行屏幕适配时人物缩放是否关联气泡、光效、点击热区,是否会因锚点位置不对而发生偏移,是否影响与打卡点、路障的碰撞检测等等。人物节点金币与任务当玩家前进时,会在路上遇到并拾取一定数量的金币,这些金币是对玩家行为的正向激励,具体表现在慢走状态遇到少量金币,慢跑状态遇到较多金币,使用加速卡/闪现卡遇到大量金币。其实金币是由前端控制随机出现的,随玩家状态不同而调整金币出现的概率和数量。当玩家点按冲按钮时,服务端经策略控制下发随机任务,在响应的数据中包含任务相关字段,游戏侧根据任务类型映射成对应任务 icon 图片名并进行加载,然后将任务布置在路面上。由于金币/任务节点只与人物节点存在关联,因此将金币/任务节点放置于人物层,便于计算与人物的距离,当距离小于一定值则判断为拾取。动效实现这次涉及到的动效不算特别多,主要集中在金币、任务 icon、按钮、人物奔跑光效上。对于较为简单的动效,不外乎对素材进行旋转缩放大小透明度以及位置变化,同时将若干个素材进行叠加,使用 Animation Clip 或 tween 动画都可轻易实现。对于较为复杂的动效一般使用序列帧,由设计师提供即可,如下图的金币旋转序列帧动画。使用序列帧时需要注意的一个点是,若不同帧之间图片的尺寸有所变化,那么 sprite 节点的 size mode 不能为 trim,同时要关闭 trim 选项,否则会导致节点在动画播放过程中发生位置偏移或宽高比变形等问题。Lynx 的容器是字节自研的 helium,该容器与 web 端存在一些差异。在与 Cocos 结合使用时,暴露出一些问题,最突出的问题是透明图片存在曝光度不对和边缘白边问题,大致的原因是 Cocos 在计算半透明纹理叠加后的颜色时给到的参数不对,导致在 helium 上出现问题。我们的解决方式是在混合模式中的 Src Blend Factor 选项设置为 ONE,同时为所有图片设置预乘。资源加载流程从用户进入页面到游戏加载完成要经过若干个步骤。当 lynx 页面完成首屏后开始加载游戏场景,当游戏场景节点均激活后,向业务侧获取主会场数据,以获取路线信息和玩家信息,加载对应路线和角色的资源,加载并完成渲染后便进入游戏,用户看到游戏画面。由于项目中游戏侧资源总量较大,于是将需要异步加载的资源单独放置于 resource bundle 中,便于使用。资源加载流程由于cc.resources.load是对单个或一组资源进行加载,每个资源的加载互不关联影响,因此想要获取游戏初始资源整体加载进度就必须要对按需加载的资源进行统一管理。我对cc.resources.load进行了二次封装,提供onProgress与onFinish事件监听能力,以对游戏资源加载进行管理,并在游戏主场景函数中对资源加载事件进行监听,达到了以下目的:获取资源总数和已成功数,计算加载进度条;提供挂载初始资源加载完毕事件回调的能力,及时使用户进入游戏;统一处理资源加载失败的情况并进行一次重试以及打印日志,提升开发效率;便于统计游戏侧初始资源加载时长;自定义字体在游戏场景中,自定义字体的需求是相当常见的,例如在加速卡按钮上显示“加速卡*2”,其中加速卡*是固定的,而后面跟着的数字是动态变化的,这些文字都要使用设计师指定的艺术字体,如图:资源加载流程在 web 中,显示文字是再常见不过的了,正常情况都绝不会将文字与性能优化挂钩,但在 WebGL 中渲染文字的方式与浏览器有所出入,绘制文字会带来较大的开销,因此会尽量选择使用图片来替代文字(ttf),而实际上位图字体就是图片,因此使用位图字体在性能上是有收益的。但设计师往往只能给我们提供 png,而不知位图字体,因此我们需要将 png 处理成可用的位图字体。cocos 提供了艺术数字资源,但其缺点也非常明显:字体必须等宽,因此 1 和 0 所占宽度一样,对导致 1 与其他数字之间存在较大间隔,设计不接受;理论上只支持 0-9,不支持小数点、加减乘除号或字母,实际上可按照 ascii 表来插入任何你想要的字符,但缺点是要对输入的字符串进行转换,不便于维护;性能优化drawCall 优化使用精灵图:将多个小图合成一张大图,满足合批渲染要求,能够有效降低 drawCall。纹理内存优化纹理加载流程项目中使用的图片基本上都有透明区域,因此使用 png 格式图片。png 不能直接被 gpu 读取,需要解码成未压缩的数据。其在内存中体积的计算公式为: 体积 = 像素个数 * 单个像素大小,每个像素都包含 RGBA 四个通道的数据,每个通道占 1 个 Byte(0-255 即 2^8,8 个 bit),因此一个像素占 4 个 Byte,即体积 = 长 * 宽 * 4 (字节)那么,若将图片等比缩小为原来的 70%,将节省内存 51%,若缩小为 50%,则节省 75%。引擎裁剪 & 自定义引擎可在项目设置中将未使用的组件取消勾选,未使用的模块将不会被打包进引擎文件,可有效降低引擎体积。引擎裁剪同时,使用自定义引擎,我们内部的同学对 Cocos 引擎进行了优化改进。代码逻辑优化及时释放不再使用的纹理资源降低远景天空的刷新率降低人物的刷新率Hacksp.Skeleton 组件砍需求设计师希望在人物运动时增加背景模糊效果,实测后发现 gpu 需要进行大量卷积运算导致性能开销增大,于是改成了仅在使用闪现卡时添加背景模糊。参考资料[1] 吃 小游戏试玩: http://fbdemos.leanapp.cn/star-catcher/[2] Cocos 官方文档: https://docs.cocos.com/creator/manual/zh/getting-started/quick-start.html#%E5%BF%AB%E9%80%9F%E4%B8%8A%E6%89%8B%EF%BC%9A%E5%88%B6%E4%BD%9C%E7%AC%AC%E4%B8%80%E4%B8%AA%E6%B8%B8%E6%88%8F[3] 预乘: https://www.zhihu.com/question/264223719
|
|