个人如何选择一个类型的游戏题材。希望:能做完,提升技术,多语言。
我们的美术资源包括一张表现“眼白”(学名是巩膜)部分的颜色贴图,一张表现“眼眸”(学名是巩膜)部分的颜色贴图,一张法线贴图和一张MatCap贴图。其中,虹膜圆形的边缘用虹膜贴图的alpha通道表达。除此之外,我们还需要一些小技巧来表现眼珠在眼眶中的遮蔽关系,这将会在后文中详说。
首先我们需要了解的是:眼球不是一个正球形,在虹膜的正前方位置有一个圆形的突起。这是因为虹膜正前方有一个液泡的结构,而整个眼球又包裹在透明的巩膜里,所以眼球是一个整体流线型,在正前方有小突起的球体。这些细节,美术的同学会进行表现。
综合来说,虹膜将会是我们的核心,我们需要重点处理虹膜和巩膜、瞳孔以及它正前方的液泡的关系。
在头发篇中,我们已经聊到了UV数据和其他类型的数据一样,可以对它进行算数运算。我们熟悉的UVTiling的功能就是通过用UV乘以一个常量实现的。对于虹膜贴图,我们也可以采用相同的处理:
vec2offsetUV=v_uv*irisSize;我们新建了一个浮点参数irisSize,并让他与UV数据直接相乘。结果和UVTiling是一样的:虹膜贴图在UV上的比例缩小了(在irisSize取值大于1的情况下),并且在UV空出来的部分叠加上了同样的虹膜贴图。
当然,我们的眼珠只需要一个虹膜。我们既希望利用常量相乘的办法缩放UV,又不需要贴图的叠加,只需要在贴图的属性中将WrapMode设为clamp-to-edge即可。
叠加消除了,我们又遇到了新的问题:贴图似乎缩放到了左下方的角落里。我们需要对坐标系做归一化处理,让我们在缩放UV的同时,贴图可以保持在UDIM正中心。
vec2offsetUV=(v_uv-0.5)*irisSize+0.5;我们的虹膜大小和位置已经差不多了,下面我们需要将虹膜向后“推”进眼球里,以表现液泡和虹膜的前后关系。我们可以使用视差贴图的方法实现这个效果。
如上图所示,灰色平面代表物体的基本网格平面,在此基础上物体有突起的表面结构,用红色曲线表示。当我们以上图V向量的方向观察物体时,我们理应观察到红色曲线上的B点,当突起的表面结构不存在时,我们则会观察到基本网格上的A点。换言之:我们需要A点上的网格数据,去实现高度在B点的渲染效果。
我们知道,高度贴图(HeightMap)表达的是物体切线空间的高度数据。也就是说,A点的切线空间高度数值(H(A))是可以通过贴图获得的。但是B点呢?我们通常会以A点的切线空间高度作为数值权重,以观察向量V的反方向(从片元指向摄像机)进行缩放,就可以大致得到B的位置坐标。这样的计算当然不能做到完全精准,但效果是我们可以接受的。
方法有了,我们需要做的第一步是获得从片元指向摄像机的向量,并将其转化到切线空间当中:
vec3worldDirPosToCam=normalize(cc_cameraPos.xyz-v_position);vec3tangentDirPosToCam=vec3(dot(worldDirPosToCam,v_tangent),dot(worldDirPosToCam,v_bitangent),dot(worldDirPosToCam,v_normal));我们可以利用得到的切线空间向量,对UV进行偏移,以偏移后的UV坐标读取切线空间的高度信息。这样我们就在A点得到了B点的高度输出:vec2parallaxUV(vec3V,vec2uv,floatiniHeight,floatscalar){vec2delta=V.xy/V.z*iniHeight*scalar;returnuv-delta;}上面的代码需要带入四个参数:V为我们刚求得的切线空间中的从片元指向摄像机的向量,uv为物体的原uv(即我们已经在皮肤篇和头发篇中使用过的“v_uv”),scalar为自定义的权重参数,iniHeight是片元的原高度数据,这个数据应该由一张贴图提供。在我们的着色器中,我们只需要用视差贴图做一些简单的像素偏移,因此没有准备专门的高度贴图,我们可以用颜色贴图的任意一个通道,或者直接使用一个常量0.5作为代替。
得到了视差贴图的函数,我们就可以把它用在虹膜上面了。
vec2offsetUV=(v_uv-0.5)*irisSize+0.5;vec4irisTex=texture(irisMap,offsetUV);vec2irispUV=parallaxUV(tangentDirPosToCam,offsetUV,irisTex.r,parallaxScale);vec3irisColor=SRGBToLinear(texture(irisMap,irispUV).xyz);我们可以用之前的缩放归一后的UV得到有视差效果的虹膜UV,用这套新UV赋予我们的虹膜贴图,得到的结果应该类似下图:
(gif传不了图,尴尬)
如图所示,随着权重数值的变化,我们的虹膜贴图应该能够沿着法线方向向前“推”或向后“缩”,同时我们也发现,我们目前的视差贴图只能达到一种近似的效果,随着权重数值增大,视差的效果也会越来越失真。因此我们在使用它时,需要注意将数值控制在比较低的范围内。
虹膜的处理已经差不多了,下面我们需要处理一下瞳孔。
完成了虹膜的视差,我们如法炮制,对我们得到的视差UV做归一化处理。区别在于,这次我们将UV归一化,这相当于将所有的UV塌陷到归一化坐标的原点上。使用这个UV采样贴图,得到像素向坐标中心拉伸的效果。
接下来,就是制作一个遮罩将虹膜和瞳孔混合在一起了。
vec2pupilpUV=normalize(irispUV-0.5)+0.5;floatpupilIndex=(1.0-length(v_uv-0.5)*2.0*irisSize)*(0.8*pupilSize);vec2irisUV=mix(irispUV,pupilpUV,pupilIndex);vec3irisColor=SRGBToLinear(texture(irisMap,irisUV).xyz)*irisColor.xyz;通过自定义参数irisSize和pupilSize,我们可以分别控制虹膜和瞳孔的大小。我们也可以为虹膜贴图自定义一个偏转的颜色irisColor,快速制作出不同颜色的眼眸。
下面我们可以把虹膜贴到眼球上了。眼球的基本材质使用巩膜贴图,我们只需要把虹膜的部分叠加在上面即可。虹膜贴图的边缘部分是用alpha通道的渐变完成的,我们可以用指数运算控制渐变的曲线强度,从而控制虹膜边缘的硬度:
vec3scleraTex=SRGBToLinear(texture(scleraMap,v_uv).xyz);floatirisEdgeIndex=clamp(pow(irisTex.a,irisEdge),0.0,1.0);vec3eyeBase=mix(scleraTex,irisColor,irisEdgeIndex)*irisColor.xyz;
目前眼球的固有颜色信息已经得到了。但是我们的眼球看上去和直接贴了一张颜色贴图没有什么区别。下面我们需要做的是:为眼球赋予“神”。
所谓有“神”的眼睛,可以简单概括为“有高光和/或有反光的眼睛”。如参考图所示,上面两张参考图中的眼睛显得更加生动和有活力,而下面两张则看上去非常死板,好似无机物。
然而,游戏中角色的眼睛并不是总能恰好反射环境中的光照,当环境有某些特定的需求或者从某些特定的角度观察时,眼睛很有可能没有足够的高光或反光。更何况,眼睛固然重要,但毕竟是一个较小的反射面,为此专门进行反射的光照计算似乎有点得不偿失。一个常见的折中办法是:把高光和反光作为贴图,永久地“贴”在眼睛表面。这样无论任何环境和角度,角色的眼睛里永远有星辰大海。
所谓MatCap贴图,顾名思义,是一张把整个材质(“Mat”-erial)的特性捕捉(“Cap”-ture)到像素内的贴图。MatCap贴图通常绘制的是一个球体,着色器会根据球体上的明暗面、高光和反射,为整个材质绘制明暗关系和高反光。美术的同学应该对MatCap并不陌生——ZBrush中用于渲染动辄上百万个多边形的材质正是使用MatCap着色器。因此MatCap有着效率极高,又足够能表现明暗关系和质感的优点。同时,MatCap的缺点也是显而易见的:无论从哪个角度观察,MatCap材质的明暗关系和高反光永远一成不变。
在我们的着色器中实现MatCap材质也非常简单:我们知道MatCap的特性是它永远正对观察方向,既然如此,得到一套永远正对摄像机的UV,用它来采样MatCap贴图即可。我们知道,法线数据表达的是物体表面片元正对的方向,因此把法线数据转换到视图空间,只取X和Y轴数据,就能得到我们想要的UV:
vec4matCapUV=(cc_matView*vec4(v_normal,0.0))*0.5+0.5;vec4matCapUV=(cc_matView*vec4(v_normal,0.0))*0.5+0.5;确定了UV,剩下的工作就水到渠成了:
vec3matCapColor=SRGBToLinear(texture(reflecMap,matCapUV.xy).xyz)*reflecAmt;vec3eyeColor=eyeBase+matCapColor;
我们的着色器已经编写完成了,让我们来看看效果:
按理来说,我们该参考的图都参考了,该考虑的变量都考虑了,该做的工作都做了,但这白森森的眼神,还是直接营造出一种纸人既视感。尤其是从较远距离观察的时候,白的发亮的眼珠更是莫名惊悚。
这是因为:眼珠和身体的其他部位一样,应该相互产生遮蔽的关系。我们的眼珠是单独制作的,所以和眼皮没有暗部遮蔽,因此在整张脸上特别出挑。这也是角色渲染的一个常见问题:我们对人脸都太熟悉不过了,以至于人脸上如果出现异于常理的现象都会触发本能的警觉。而且当其他的部分越趋近于真实时,这种恐怖感越严重。
如果是一个静态的部位,这个问题非常好解决:烘培一张AO即可。但是对于角色来说,绝大多数的角色眼球是需要骨骼动画的,直接把AO烘培在眼球上显然不可取。我们需要做的是在眼球的模型前方再新建一个遮蔽的模型,给它赋予一个AO的透明贴图,单独作为AO保留在模型上。这个模型除了AO将不会起任何其他作用,因此只需要给予一个基本的Unlit材质,也不会消耗额外资源。这种做法,也是包括UE4在内的许多引擎选择的做法。
我们对人物渲染的探索到这里就可以成功收官了。在我们试图解答一个个渲染问题的过程中,我们获得的不仅是皮肤、头发和眼睛,同时也包括了:
相较于皮肤,头发可能就更加令人挠头了:
在美术层面,我们需要以发束为单位处理大量的多边形并堆砌成各式各样发型的形态;
在渲染层面,我们需要让着色器使用alpha通道,剔除多边形上不需要的像素,呈现出单独纤细,整体密集的发丝形态。
需要注意的是,我们需要对美术资源做出一点小小的要求:固有色贴图(包含alpha通道)必须以单独alpha通道的.tga格式存储。我们将会编写的着色器并不能正确渲染带有像素剔除的.png格式图像。
首先我们仍然需要解答一个问题:什么样的效果能够让头发更真实?
观察上图,首先抓住人眼球的无疑是她头发上长条状的高光,进一步细看,我们可以注意到长条状的高光有两种颜色变化:一种是我们熟悉的偏白色高光颜色,强度也略高;另一种则是比头发固有色明度和饱和度略高的高光颜色。
在上图中我们可以看到两条看似相交的高光,这当然是他发型的梳理方式而决定的。这告诉我们头发的高光基本遵循每一根单独发丝的走向。在同一束头发中所有的发丝走向基本一致,所以他们的高光聚集在一起形成了条状。
在这个例子中,我们同样可以看到遵循发丝走向的两种颜色的高光,而且高光的位置似乎集中在发型弯曲的位置上。
综合起来,我们可以观察到的规律是:
我们知道,Specular表述的是材质的反射光线,无论材质表面的粗糙度如何,Specular光线传播的方向都可以从宏观上看作一个锥形,在这个锥形范围内光线的传播是平均的,这也是为什么我们在材质表面观察到的高光通常是一个圆形。这种光线传播的特性,物理上称之为各向同性(Isotropy),其涵义正如其字面意思:“在各个方向上一致的”。
然而在参考图中,头发上的高光并不是圆形的。高光只有在单个发丝上出现,而从发丝之间的横向来看,并没有产生高光的条件。整体来看,头发是一种只有垂直方向会产生高光,水平方向没有高光,垂直方向的高光密集排列在一起成带状的材质。这种在各个方向上不统一的特性,称之为各向异性(Anisotropy)。
除了头发之外,任何物理上由无数会产生高光的细丝密集组合成的材质,都会表现各向异性的高光特性,比如丝绸、大多数的晶体、抛光的木材、拉丝抛光处理的金属等。
目前我们在游戏中看到的大多数头发各向异性渲染效果,都基于早在1989年发表的Kajiya-Key模型。那么,什么是Kajiya-Key模型?
既然头发的各向异性特征表现在高光上,那么,我们应该从Specular入手。
然而,这个公式仅适用于各向同性的情况,对于头发的各向异性特征并没有考虑进去。
得到了T向量后,我们如法炮制继续套用“N·L”方法,这需要得到T在半向量上的投影。这个投影实际是T和N夹角的正弦,而点积只能获得两者夹角的余弦。所幸的是,我们可以通过正余弦定理,通过换算得到正弦:
vec4worldViewDir=cc_matView*vec4(0.0,0.0,1.0,0.0)-vec4(v_position,0.0);vec4worldHalfDir=normalize(normalize(cc_mainLitDir)+normalize(worldViewDir));floatTHdot=dot(normalize(T),normalize(worldHalfDir.xyz));floatsinTH=sqrt(1.0-pow(NHdot,2.0));
在上面的代码中,cc_matView和cc_mainLitDir都已经在皮肤篇中出现过了,分别返回的是ViewMatrix和光源方向。
这些看上去都比较简单。那么问题是:我们如何得到向量T?
我们知道,物体的顶点存储了切线空间数据:以顶点法线方向为一轴,顶点切线(与顶点法线垂直,与表面平行)为另一轴,与顶点法线和切线都垂直的第三个向量为第三轴。其中顶点法线和顶点切线已经包含在网格的顶点数据当中了,模型的同学已经帮我们处理好了光滑组或软硬边(取决于美术同学用的是3dsmax还是Maya),并按照需求提供了法线贴图。第三个向量,通常被称为副切线向量(Bi-tangent,或Bi-normal),我们可以根据它垂直于顶点法线和顶点切线的特性,用叉积计算得到它:
v_tangent=normalize((matWorld*vec4(In.tangent.xyz,0.0)).xyz);v_bitangent=cross(v_normal,v_tangent)*In.tangent.w;
其实在CocosCreator的PBR着色器中,副切线向量已经为我们计算好了,我们可以通过v_bitangent使用它。
目前为止,我们已经把Specular与发丝的走向建立了联系,但是我们的Specular依然是模型的高光,看上去并不像头发。我们需要使用一张发丝的灰度图,作为一个数值权重来偏移副切线向量的方向,使我们的高光向发丝的方向拉伸,从而更像头发的形态。
首先我们需要知道的是:切线空间的数据是基于物体表面的切面的,而物体的表面又由法线方向决定。因此我们对副切线向量的偏移,一定是朝着法线的方向偏移。
接下来,我们需要一张发丝的灰度图作为拉伸的权重。这张灰度图可以是一张使用头发模型UV的贴图,或者一张四方连续的贴图。如果是后者,我们要为它写入相应的UVTiling的功能。
vec2anisotropyUV=v_uv*anisotropyTile.xy+anisotropyTile.zw;vec4jitterMap=texture(jitterTex,anisotropyUV);
我们先把法线数据与权重灰度图相乘,将法线方向加以扰动,除此之外,我们还需要相加一个位移权重的自定义参数,这个参数的意义在后面会体现出来。让后将其与副切线向量相加。这与我们求得H向量的方法是一样的,归一后我们得到的就是副切线向量与扰动后法线方向向量的半向量。
求得我们的T向量之后,对其进行点积计算,换算为正弦,代入我们的简单Specular公式,各向异性的高光就的出来了。在这里我们还使用了GLSL的smoothstep函数,类似于mix函数它会把输入的参数投射到定义的最小值和最大值的区间中,并在两个极值之间生成一条平滑的过度曲线。
floatanisotropyIndex(floatoffset,floatfactor,floatamt){vec3jitterT=v_bitangent+(v_normal*(offset+factor));floatTHdot=dot(normalize(jitterT),normalize(worldHalfDir.xyz));floatsinTH=sqrt(1.0-pow(NHdot,2.0));floatatten=smoothstep(-1.0,0.0,NHdot);returnpow(sinNH,amt)*atten;}
现在,Specular已经基本遵循发丝的走向了,但是我们的高光似乎太强烈了,这是因为我们并没有考虑头发自身相互遮蔽的问题。解决它非常容易,只要把头发的AO叠加到高光上即可。
floataoFactor=mix(1.0,0.0,pbr.x);
最后就是联动的环节了。回想一下我们观察的参考图,我们需要使用我们编写的各向异性函数生成两道高光带,还记得我们在扰动副切线向量时写入的位移参数吗?给两条高光带分别带入不同的位移参数值(hairSpecMOffset,hairSpecAOffset),他们就不会重叠在一起,并且你可以把高光移动到模型弯曲度较高的地方,获得更真实的效果。除此之外,他们分别有各自的强度参数(hairSpecMAmt,hairSpecAAmt)和颜色参数(hairSpecColor01,hairSpecColor02),我们还可以给一个总强度进行整体协调(hairSpecIntensity)。
vec4hairSpec=clamp((anisotropyIndex(hairSpecMOffset,jitterMap,hairSpecMAmt)*hairSpecColor01*hairSpecIntensity+anisotropyIndex(hairSpecAOffset,jitterMap,hairSpecAAmt)*hairSpecColor02*s.albedo*hairSpecIntensity)*aoFactor),0.0,1.51);
做到这一步,我们的各向异性高光的函数已经基本就绪了,然而我们又遇到了与皮肤篇中相似的问题:我们在哪个通道输出高光呢?
你可能已经想到可以利用我们的各向异性高光的函数调整roughness通道以达到控制Specular输出的目的,但这个结果并不是我们想要的。我们的函数返回的并不是高光的遮罩,而且我们并不希望在高光的部分看到如同镜面一般的反射。更何况,标准PBR的高光仍然存在,我们更加不希望看到各向同性和各向异性的高光同时出现。既然如此,最简单的办法是:把roughness设为常量1,消除所有的各向同性高光,然后把各向异性高光的输出叠加在albedo通道上。
我们的着色器已经编写完成了,但我们的工作还没有完成。如何使用这个着色器才能呈现最好的效果呢?
新建一个材质,赋予我们的各向异性着色器。将Technique设为1-transparent。
首先将各个贴图赋予到相应的通道上。法线贴图对应法线通道,AO贴图对应Occlusion通道,固有色贴图对应Albedo通道。
开启USEALPHATEST,使用alpha通道剔除不需要的像素。这里可以使用红通道或alpha通道调整剔除的阈值,令“抠图”更干净。
做到这一步,头发该有的样子应该有了。然而你会发现,头发的前后关系似乎有点奇怪。你需要展开编辑器最下方的PipelineStates标签,在DepthStencilState标签下开启DepthWrite,确认DepthFunc设为Less。
当然,在默认情况下,模型的背面是不会被渲染的。如果需要实现双面材质的效果,在RasterizeState下的CullMode设为None即可。
做到这里,我们在CocosCreator中重现的经典Kajiya-Key模型头发着色器就基本完成了。
今年由于疫情原因,CiGAGameJam改为线上进行,在经过两个月的报名,与两个月的漫长评审期之后,主办方最终从330款游戏DEMO中选出了10款入围今年最佳GameJam。Cocos引擎团队5位同学Santy、YunHsiao、ArthurWang、jiaxin、gameall3d自发组队,加一位音乐外援妹子SHJRI一起参加了这场极限开发活动,很幸运,作品《消费主义》成功入围!这次我们联系6位成员,针对开发历程进行了专访!
这次的思路是希望能让玩家通过第一人称的生产者角度,来理解和体会消费主义背后的商业机器,和每个个体扮演的角色。
YunHsiao:Santy是我们CocosCreator2D组的,负责资源加载系统等;ArthurWang、jiaxin、gameall3d和我都是CocosCreator3D组的,负责编辑器开发、引擎开发等。
GameJam中我们其实没有固定的分工,我们都是一起参与,碰撞灵感;到了实现玩法时,引擎都再熟悉不过,任务拆分相对非常自然。每个人都全力参与其中,以模块为单位,比如相机控制、场景生成、抽象逻辑、UI、模型资源对接等,在一个git仓库内大家共同协作开发。
YunHsiao:题目发布时我们还没下班,看着直播放出题图就开始明目张胆地划水,一头雾水有一句没一句地开始解读。
图片为2020GameJam题目
YunHsiao:讨论了许久后有人从满地的快递箱看出了图里讽刺消费主义的味道,我们忽然觉得找到了图的灵魂,所有的细节都有了明确的主题指向(快递是消费品,地面的裂缝是内心的欲望沟壑,墙外的蚂蚁是让人心痒的诱惑等等),开始往这个方向探索,看能做点什么,和这幅画要表达的东西共鸣。
gameall3d:刚开始我们也思考了各种游戏类型,比如地板会塌陷的多人大乱斗游戏、蚂蚁塔防游戏、密室逃脱等等,但觉得都不够理想,经过一晚上的讨论,我们决定做一个展示商人如何通过各种手段极限压榨消费者的游戏。
YunHsiao:与其展示一个个体在整个环境下的挣扎,让玩家去勉强共情,不如直接把屠刀放到玩家手中,真正体会一把收割的角色,从收割过程中去理解资本机器的运作,进而在生活中不再被同样的手段迷惑。
我们在离提交还有4个小时的时候还有新的点子,但冷静下来觉着肯定完成不了了,所以只好放弃,但一些小的设计还是尽可能地加到了作品中,包括一些小的不明确展示给玩家的小障碍,也包括我们平衡过的数值,这些都让这个作品略微完整了一些。
SHJRI:考虑到游戏的机制会因玩家的操作而在画面和数值上加减人数,所以音乐随着游戏的进程,比如人数的递增,加入新的元素。元素来自于画面和内容,比如鼓的部分以beatbox来表现,加以动态音乐的形式,增减轨道来丰富玩家反馈。
YunHsiao:前期尝试时我们也陷入了受限的思路,本质上还是没有摸清楚到底什么才是那个不可再分的,消费主义的最小子集。直到后来深入思索一番后,发现从“欲望”入手,一切似乎都可以变得清晰起来。
我们的时代是自由的。自由意味着每个人都可以追求欲望。这意味着一个巨大的市场,掌控了更多人的欲望,就掌控了更多……资本。
它是我们会一个接一个下单取快递的原因,它像被蚂蚁一般无时无刻不在挠得我们心头犯痒想要更多,它是我们明明已经有了那么多盒子可心中的空洞和裂缝还是越来越大的原因。
消费主义不过如此了。
YunHsiao:结合我们几个的实际情况,我们的长项是逻辑(同时短板是美术),设计一个相对复杂精巧的系统更接近我们的主场,这正符合资本机器冰冷严谨的运作风格。
引擎更是没理由不用自家的狗粮,所以基础设施的长项是骨骼动画(instancing),加上相对合理数量的粒子和物理,所以我们做的东西,更像一个实时策略类游戏了噢。
实时策略游戏的核心正是系统构建,虽然我们都没有真正专业地做过,但这听起来是一个我们可以尝试搞的东西!
YunHsiao:我们需要一个模型,一个最纯粹又在直觉上能让人亲近的情景,来让玩家“感受”。
我们把基本元素确定为消费者和产品:消费者在这世界中大量随机分布着,每个消费者都有随机数量的“快乐值”、“欲望值”和“剩余价值”等几个基本属性。
玩家作为生产者,拥有固定的初始资本,决定要在哪里投放产品,尽可能榨取最多的“剩余价值”。
Creator3D制作粒子效果
Santy:首先CocosCreator3D对资源的支持很全面,包括网格、材质、动画都可以通过FBX、GLB等格式快速导入编辑器中,这对于原型开发非常方便。
在游戏设计中,我们希望场景中能同时显示上千个带动画的人物模型,从而模拟出市场的效果,在不启用GPUInstancing时,Drawcall很容易达到200以上,造成性能严重下降。
启用Instancing前效果图
而在启用了instancing之后,这一问题被非常轻松地解决掉了,Drawcall数量降到了个位数,在手机浏览器上也能非常流畅地运行,留给了我们更多的空间去做GamePlay方面的发挥。
Santy:CocosCreator3D保持了CocosCreator的调试风格,在修改场景和代码后,你不需要经历漫长的编译过程,就可以非常方便地使用智能设备进行效果测试。只需要扫一扫编辑器上的二维码即可快速预览效果。
相较于其他作品,我们的游戏原型,更容易让大家玩起来。得益于web的快速分发能力,我们可以使用CocosCreator3D将游戏发布到web平台,并使用iFrame嵌入到我们想要嵌入的网页中,玩家不需要下载其他内容,即可开玩。这对于一个游戏原型来说,是非常重要的,越容易接入玩家,则意味着开发者能获得更多的反馈,从而调整自己的设计。
另外,版本的更新也是非常方便的,当更新版本后,只需更新服务器上内容,而所有玩家将获得最新的内容。
总体而言,使用CocosCreator3D开发游戏原型非常方便与快捷,虽然目前还有部分功能还未集成,例如查看运行时节点树等。但CocosCreator3D将会越来越完善,成为原型开发的高效工具。
ArthurWang:要说完全不累是假的,周六那天我大概熬到了凌晨4点,最后和队友交流都已经不清醒了才去休息,第二天也是强打精神开发。但看到我们的作品被队友很认真地游玩之后还是很高兴。
那天结束之后感觉几个人都已经完全肝废了,Santy更是吃着饭睡着了,但看到直播中我们的作品得到的反馈又觉得这一切都值了。
ArthurWang:成品是大家思维火花碰撞的结果,经过不停地迭代,最终的成品其实和我们预先设定的想法已经不是严格一致了,在做的时候不停地有新的想法冒出来,作品本身自己会逐渐成长。
gameall3d:这次的队伍没有美术,所以画面上还是有所欠缺,连个像样的封面都没有,但也因此让我们能更加专注在玩法上进行思考,一群人在一起边思考边制作游戏的体验真的很棒。
以上就是本次的专访内容,能够一起做一些真正想做的游戏,抓住一闪而过的灵感,并使之变成现实,这段过程和感觉都值得铭记,相信这也是GameJam迷人的所在。
《吃鸡联盟》是由南京壹游网络科技有限公司基于CocosCreator3D研发的一款IO竞技小游戏。这支成立于2017年的团队,在经历了创业初期H5页游的失利和中期的迷茫时期后,如今坚定地选择了走小游戏开发路线,团队负责人笑称自己“是芸芸小开发者的真实缩影”。目前团队共有4人,分别担任项目制作人书生(兼行政、商务、运营…..)、技术开发毛毛熊、美术设计啊翔和策划汤包五十六,“能者多劳”,作为公司负责人的书生很苦逼的对应了N个岗位。
《吃鸡联盟》立项于2020年3月,疫情期间,成员们就通过远程办公的方式不断寻找新的产品方向。团队之前的产品都是关卡制,受限于团队规模,关卡制的游戏又对内容的要求比较多,制作成本较高,此前的产品效果不是很理想。考虑到小游戏的特性、用户需求以及团队本身的能力,最终选择了IO类作为后期团队的主要研发方向。
游戏融合了吃鸡+IO元素,玩法很简单,拖动人物即可控制行走并发射道具,玩家可以通过灵活的走位来发射子弹攻击敌人,也可以通过掩体来躲避敌人攻击。拾取游戏内的紫钻可以提升等级,进而对技能进行解锁和升级,比如回复血量、提高攻击力、提高攻速、提高射程等。
除了缩圈机制之外,《吃鸡联盟》区别于其他类型射击游戏的地方,还表现在子弹的速度上。大部分的射击游戏属于不对称攻击(闯关类的都是玩家射速高、怪物射速慢)和硬扛类(射速相同拼武器和血量),《吃鸡联盟》的子弹速度相对较慢,有比较多的可操作性,玩家可以通过操作躲避敌人的子弹,利用走位去攻击其他玩家,这样的机制更能让玩家体验操作的乐趣。
传统方案上,对于这种场景的设计,大家首先想到的肯定是物理引擎,通过设置建筑物和障碍物的碰撞体(Collider)来阻挡人物的行动。
在这种思路下,如果场景中的建筑物和人物比较多,会造成比较严重的性能问题,因为每一帧内对每一个人物和每一个障碍物都需要做碰撞检测,计算量是:N(人物)*M(障碍物)。再加上飞镖的碰撞检测计算量,在不支持JIT的iOS平台上可能会有不小的性能压力。当然,基于物理引擎的碰撞检测方式也有不少可以优化的点,比如说:
但是这些优化的效率都远远不如《吃鸡联盟》中所应用的有向距离场碰撞系统。
下面就来看看开发团队倾囊相授的基于CocosCreator3D如何实现这样一套场景碰撞检测系统吧!
首先,大概实现的原理是通过插值计算得出任意点的有向距离数据,然后与单位的碰撞大小做比对,来检测单位是否可以通行。实例图如下:
如果你做的是类似于《王者荣耀》这样的伪3D游戏,只需要考虑平面位置因素,不需要考虑高度,不需要太精准的碰撞判定,并且地图元素固定不会变动,这套高效的、基于有向距离场(SDF)的地图碰撞系统可以参考使用。
将地图划分为N*N个格子,每个格子的四个角存储有距离数据,这些数据是每个角所在点到最近的障碍的距离。如下图:
深色格子不可通行,交叉点数字代表该点到最近的不可通行格子的距离(下文称“有向距离”)。
通过有向距离数据,我们可以通过计算差值的方式算出任意点到最近障碍的距离。
如图所示,在判断精灵是否可通行时,只要在精灵当前位置所在格子上的数据进行一次插值计算,即可判断是否可通行,非常高效。
既然这么棒,那么,要怎样获得这些数据呢?
就是将地图划分为N*N个格子,每个格子标记为可通行/不可通行。当然,划分的格子越多,精度越高。建议使用高度图来存储通行数据,高度图长这个样子:
这是一张128*128的图片,代表将地图划分出的128*128个格子。图片上每个像素点的颜色表示是否通行,黑色为障碍,白色为通行区域。
准备好图片后就需要读取像素信息了。
(关于原生url的获取,暂时没太好的方法,只有先load资源然后再获取nativeUrl值。如果有更好的方法请告知)
//获取指定图片文件的像素数据。返回Promise//path写到文件名就行,不需要加spriteFrame和后缀loadImagePixelData(path:string){varself=thisreturnnewPromise((resolve,reject)=>{loader.loadRes(path+"/spriteFrame",SpriteFrame,(err,res)=>{if(err){console.error(err)returnreject();}varspriteFrame=
{data:[0,0,0,255,0,0,0,255,…],height:128,width:128}data数据每4个一组,存储了一张图片上每个像素的RGBA值,顺序则是按照由左向右、由上往下的顺序(遵循canvas坐标系)。将颜色数据转换为二维的布尔数组,即为地图每个栅格的通行数据。
实现代码:
//高度图数据转化为地图通行数据//imgData格式:{data:Uint8ClampedArray,width:number,height:number}imgData2PassData(imgData:any){vardata=imgData.data;varresult=[];varwidth=imgData.width;varheight=imgData.height;if(data.length
(比较麻烦的一步。这里介绍一个笨办法,如果有更简单的办法,欢迎告知)
每个角(即栅格划分线的交叉点)都需要计算一次。如果你将地图划分成了N*N个栅格,那将有(N+1)*(N+1)个交叉点的有向距离数据需要计算。
对于每个交叉点:
首先要遍历所有的栅格。如果是不可通行的栅格,判断栅格和当前点的方位关系,决定用栅格的哪个角去计算到当前点的距离。
决定好了后,计算两点距离。所有不可通行的栅格都要和当前点计算距离,最后取它们的最小值,即为有向距离值。
差不多是这个意思
分三步:
第一步:判断精灵当前位置属于哪个格子,这个很容易;
第二步:获取格子四个角的有向距离,并计算插值;
插值计算代码:
calPointDis(pos:Vec3){vargridLen=32;vargridPos=this.nodePos2GridPos(pos);if(this._block[gridPos.y]&&this._block[gridPos.y][gridPos.x])return0;varposZero=this.vertexPos2NodePos(gridPos.x,gridPos.y);varparmX=(pos.x-posZero.x)/gridLen;varparmY=(pos.z-posZero.z)/gridLen;vardis_lt=this._distances[gridPos.y+1][gridPos.x];vardis_ld=this._distances[gridPos.y][gridPos.x];vardis_rt=this._distances[gridPos.y+1][gridPos.x+1];vardis_rd=this._distances[gridPos.y][gridPos.x+1];vardis=(1-parmX)*(1-parmY)*dis_ld+parmX*(1-parmY)*dis_rd+(1-parmX)*parmY*dis_lt+parmX*parmY*dis_rt;returndis;}第三步:最后取得的数值表示精灵体积半径为多少时才能通过,否则判定为阻拦。
游戏中玩家使用摇杆控制角色时,如果撞到墙面了,肯定不可以让角色立刻停下来,那样的操作体验就很糟糕了。通常的做法是让角色沿着墙面滑行。
基于SDF的碰撞系统有一套处理这类情况的方式,即通过计算碰撞法线来得出玩家移动时碰到障碍后的正确方位。
计算碰撞法线方向的代码:
calGradient(pos:Vec3):Vec3{vardelta=0.1;vardis0=this.calPointDis(newVec3(pos.x+delta,0,pos.z));vardis1=this.calPointDis(newVec3(pos.x-delta,0,pos.z));vardis2=this.calPointDis(newVec3(pos.x,0,pos.z+delta));vardis3=this.calPointDis(newVec3(pos.x,0,pos.z-delta));varresult=newVec3(dis0-dis1,0,dis2-dis3).multiplyScalar(0.5);returnresult.normalize();}具体的处理碰撞的代码:
《最强魔斗士》沿用了当前非常火的弓箭类操作设计,移动虚拟摇杆即可控制角色移动躲避,停下来的时候会自动对敌人进行攻击。角色会发射子弹自动攻击怪物,省去了选择攻击目标、选择技能、进行攻击的步骤。这种允许玩家单手操作的方式,更适合广泛的休闲用户,同时又赋予了玩家一种「PlaytoWin」的乐趣。在玩家成就反馈上,用相对慢速但可永久保存积累的装备+秘籍系统,搭配每局战斗中临时获得的局内等级+技能,让玩家在局内大概2秒杀一怪、30秒清一关、60秒升一级、10~15分钟通一关,能在游戏过程中体验到飞速成长的即时满足感。
戳链接查看游戏视频:
游戏采用了2D场景+2D弹幕+3D怪物和角色的做法:
毕竟是腾讯光子的大厂作品,《最强魔斗士》里无懈可击的高品质美术令人由衷赞叹。甚至有多个游戏团队看到这种美术品质后,惊叹之余,转身就把自己公司里处于立项早期的弓箭类项目直接砍掉了——不是技术上做不出来,而是真心没法做到这种美术水平。
《最强魔斗士》除了美术细节之外,在其他方面的细节上同样打磨了很久。
在装备-技能搭配的策略空间上,玩家可以自己搭配出不同的打法流派。不同的套装对应不同的打法,可苟可浪。即使在同样装备的情况下,有时欧皇眷顾,局内所有技能不断正向叠加,而有时则非酋附体,始终抽不到对应技能,这两者完全就是全场压制和满地找牙的区别。所以在《最强魔斗士》里,装备强度+运气水平+操控水准,构成游戏过关三个核心要素之间的数值平衡,其实非常微妙。
顺便提示一下:关卡里随机给予的局内技能,其实并不是完全随机的,所以并不是表面上看起来的那么简单哦。
我们有专门的小组开发关卡编辑器,除了实现传统的刷子等地表编辑功能外,还有一个更上层的抽象-岛屿,通过表格配置以及一定的随机规则,可在工具层自动拼接岛屿生成完整的关卡,这是支撑目前数百上千个关卡制作的重要能力。
目前产品里的角色是由武器决定外观的,所以换装系统并不复杂,武器决定了主角使用的整个模型和贴图,不过同动作的人物形象是复用骨骼和动画的,这能节省不少资源量。
不过针对如此大量同屏精灵数的情况,我们还是做了比较多的针对性优化才避免了运算峰值带来的卡顿。
主要的优化方案大致有这些:
Cocos团队:v1.1已经自带GPUInstancing支持,不仅支持静态模型,还支持蒙皮模型的Instancing合批。
目前来看对CocosCreator3D的性能表现是比较满意的,beta版本缺乏的一些对性能特别重要的组件也已经陆续支持了,据了解1.1版本还会支持GPU粒子系统,把性能上留下的一块短板补上,这个是我们特别期待的。至于性能优化方面,对于大型的复杂游戏来说,即使引擎的通用功能性能再好,都避免不了要定制化部分实现,从这个角度来说,希望Cocos引擎后续在用户定制与扩展方面提供更好的支持,这样能降低用户直接修改引擎源码的需求和维护成本,变得更加友好。
Cocos团队:v1.1的粒子系统开始支持利用GPU运算能力进行模拟,大幅度提升运行性能,特别是在不支持JIT的iOS设备上,可以愉快地增加特效的使用啦。
目前我们也构建了安卓App版本,运行性能高非常多,不过暂时没有发布计划,从能力上来看CocosCreator3D是能满足跨平台需要的。
Cocos团队:原生性能一直是我们非常重视的关键指标,开发者们可以尝试把自己的游戏发布到原生平台,可能会有惊喜哦。
按照之前的一些经验,我们希望《最强魔斗士》这个项目在动作层面上拥有更好的表现力,同时也有更好的移动手感、更加流畅的移动体验、以及在整个美术制作流程和表现力上有更高的上限,所以最后我们决定试用3D的呈现方式。
45°的视角从对抗模式来看,相较于弹幕体验,会更接近传统RPG的视角表现方式。在这样的视角上,可以突破纯弹幕的玩法设计禁锢,扩展更多的设计空间。比如后续我们会设计更丰富的武器体验,甚至近战等等,同时45°的视角也会更适合表现有压迫力的大型怪物,例如游戏中的Boss战斗,玩家体验就会更丰富一些。
在CocosCreator3D引擎下,项目组内部针对游戏的关卡和怪物都搭建了比较高效的编辑器,大幅度提高了关卡制作的效率。游戏前三章的体验量级,就有600-700个不同的关卡小岛,没有高效的编辑器是完全无法跟上内容消耗速度的。
美术涉及到的工具和岗位都算是行业中比较常规的标配,二维绘图软件及3dsmax,岗位有交互、视觉、原画、3D动效设计师这些。相对比较有挑战的是引擎的选择。产品在小程序上发布,角色需要360°自由旋转、射击,用2D图素就不那么好表现,图量也会很多,权衡之后用3D,选用CocosCreator3D,兼固了开发及美术3D需求。由于工具比较新,人力有限,美术的一些效果功能都是对应岗位的同学提给Cocos那边帮忙实现,相当于联合的技美。
特效方面:采用CocosCreator3D编辑器开发制作,粒子系统、模型、序列图都结合使用,较多采用小型特效贴图,在编辑器里以2D模式,组合搭建出不同的动画特效。比如游戏里的子弹及受击,部分结合了粒子、模型、系列图等,单图居多,特效师做好每个子弹样式,由程序去实现弹道逻辑,比如飞行、抛射、折返、追踪、多弹道等不同效果,这种方法能保障在全屏群攻的时候,还能流畅地操作。
UI界面动效方面,分解界面素材,针对每个UI节点做动画。有些也需要程序协助触发的动效,比如技能选取,特效设计师先做好选取技能前后所要表达的特效文件,然后配合程序做好逻辑接入。
角色的换装是在三维软件里制作好模型动画,导出fbx格式,合入到CocosCreator3D里面,皮肤跟武器是分开的,可以自由搭配,并能实时旋转预览,这也是3D的优势。
这里介绍两个魔斗士里面的技能组合:弹道增强+追踪箭/折返子弹+背刺暴击,类似的思路,一方面这样的技能组合能够带来足够的视觉冲击力提升;另一方面,通过核心技能的搭配,可以达成1+1远远大于2的强度体验,包括能够突破一些特殊地形阻挡的限制,后续也会设计更多类似的技能组合,敬请期待哈~
我们希望能够通过外围的策略系统提供给玩家更多的长线追求和策略技能选择,主要是下述几个方面:
一方面是尽快提高我们更新关卡的速度,能够跟得上玩家消耗内容的速度;另一方面也是前面提到的挑战玩法,给平台期玩家提供了更多持续游戏的动力,后续也会继续在这个方向发力,给平台期的玩家提供更多有趣有深度的新玩法模式。
目前上线阶段还只是很少量级的数据测试,从测试数据结果来看,基本符合我们的预期吧,玩家的在线时长数据比较可观,这也为我们后续继续迭代内容提供了更多信心。另一方面我们也希望可以稍微降低前期的关卡难度,以及优化最基础的体验(摇杆手感、镜头逻辑、稳定帧率等等),希望有更多的玩家可以体验到这个玩法的深度乐趣~
在CocosCreator3Dbeta版本阶段,引擎和工具在稳定性以及易用性上面有较多不足,不过随着版本迭代,我们能感受到引擎的进化非常快,对bug的响应及修复都非常敏捷。目前到了正式版阶段,我们开发团队认为问题不多了,引擎运行层面比较稳定,主要是编辑器方面的稳定性希望进一步加强。
非常接近,涉及3D的部分需要看一下文档,其它方面可以无缝切换。
功能丰富性能强大、使用上很简单符合过往经验,IDE集成度高,对团队协作支持得很好,代码开源对性能分析和优化很友好。建议方面还是集中在编辑器,希望有更高的稳定性和扩展能力,进一步提升开发效率。
《欢乐贪吃龙》是由SK2GAME基于CocosCreatorv2.2研发的一款3D休闲小游戏,游戏画面卡通精美,玩法简单,玩家将扮演一只“贪吃龙”,在3D大场景中,捕食各种可爱又凶残的怪物,享受毁灭敌人的快感。
SK2GAME团队成员共20人,主要来自原七道神曲项目组,包括原博雅,腾讯等。在积累了相对深厚的手游研发运营经验后,于2019年正式进军小游戏研发领域,目前我们也在摸索各大小游戏平台的核心用户玩法,团队擅长跑酷,音乐,休闲动作类,模拟经营类,rougelike等游戏品类的研发和制作。目前团队资本结构相对纯净,属于天使轮。
《欢乐贪吃龙》这款游戏玩法很简单,进入游戏主界面后,玩家可以在形态各样的贪吃龙中选择一条喜欢后进入副本,通过捕食各种怪物即可积累能量,能力积累到一定程度,可以喷射高能量的龙炎,进入无敌状态,毁灭一切敌人,顺利通关副本。
不同的贪吃龙除了有体态有迷你、小、中、大的区别之外,不同的皮肤也会在比如吞噬距离、速度、爆发冲刺、承伤等方面有着不同的Buff加成。
主场景是玩家对龙的周边系统的认识及选择。
点击开始游戏打开任务界面,任务界面是当前副本的过关要求,全部任务完成,才能过关。
副本场景主要包括3D场景、玩家操作的龙、场景中的怪物、场景中的各种buff及收集物。各模块的交互判断主要是碰撞检测,例如龙和怪物的碰撞检测,龙的处理逻辑:
怪物的处理逻辑:
结算场景主要是对副本场景的一些游戏行为进行总结,游戏资源的奖励发放。
项目正式制作始于2019年12月初,第一周用一些测试3D模型,做了个简单的3D场景,一条龙在其中漫游,顺便测试了3D模型+物理场景性能的峰值。达到3万多面时,iphone6就只有不到20帧,安卓低端机30帧左右。后续开发过程中,每个功能都会考虑到性能的问题。
《欢乐贪吃龙》项目由1个策划+2个前端开发+1个原画+1个UI+1个特效(技术美术)组成,游戏中场景配置优化,怪物和龙的动画及其他游戏特效效果,都由前端开发和技术美术一起配合完成。过程只有一句话:技术美术真香!
前端只要提供好一些挂载组件和prefab,效果由美术在编辑器中调整测试效果,效率提高不止一个档次。
当前版本只有1个场景,但是设计时,有考虑到场景变化可配置,将地图分块,如图所示:
场景地图分成n个小块,每个block节点都是地图的位置节点,上面挂载着一个地图块加载组件MapBlock。
每个block节点显示掩藏是受父节点RootNode上的ActiveState控制。这个脚本的主要工作是对子节点遍历,定时updateTime(毫秒)计算3D摄像机相对上次移动了多少距离,当大于updatePosStep时,计算子节点是否在3D摄像机视线中,进行掩藏显示操作。block节点显示,就会加载显示对应的地图块,掩藏将显示的地图块掩藏(优化性能)。
因为是3D场景,所以需要获取当前3D摄像机去计算。上面的代码是当前屏幕的尺寸viewRect与当前block节点在3D摄像机中映射到屏幕上的rect比较是否相交。相交就是需要显示。UIHelp.getRectInView方法:
关卡过关条件的任务,是每一关配置一个任务库,随机生成。怪物的配置是配置在如下的prefab中。怪物节点也是受ActiveState组件控制,实现动态显示加载。
这里用prefab配置关卡的怪物生成,是为了方便策划,后期会写一个插件,将此prefab转换成配置文件,这样数据会小很多,毕竟prefab里面的数据有很多没用的。怪物创建有一个管理类管理,里面对怪物的创建、销毁用了对象池。同样怪物的显示掩藏逻辑也在里面处理,原理同地图生成。
《弹无虚发》采用单摇杆+自动瞄准的方式,为玩家省去了选择攻击目标、选择技能和发动攻击等步骤。
游戏操作很简单,手指在屏幕任意处滑动可以控制英雄移动。玩家除了要移动躲避怪物之外,还需要考虑体力,因为移动速度越快,主角消耗的体力也就越高。若因过快的移动导致体力下降,移动速度会随之变慢,容易被怪物接近攻击。相较于传统的射击游戏,《弹无虚发》更添了一层策略趣味。
在竖屏单手操作的游戏中,不用选择技能即可直接攻击,给了玩家一种耳目一新的操作体验,并且游戏的操作难度也在休闲用户可接受的范围内。
随着战斗视角的转换,不论是树木、怪物还是主角、道具等3D阴影方向也随着发生变化:
怪物们奔跑时有气泡扬起的效果:
游戏设有枪支系统,包含丰富多样的枪支选择,如冲锋枪、沙漠之鹰、高达枪、黄金AK等等,不同的枪支在速度、射速和威力方面具有不同的表现力,可以满足玩家对于枪支的战斗和审美需求,同时这些不同种类的枪支在发动攻击时,也具有不同的效果:
沙漠之鹰
猎户者,子弹是绿色的
所有怪物被击中时带有反白效果。
主角被怪物攻击时带有反红效果。
游戏中设计了不同的增益道具,游戏过程中会随机产生各种各样的增益道具,如子弹数增多,防护罩,分身等,能够帮助主角更好地打击怪物。
我们团队里的每个人都十分热爱游戏,包括但不限于主机游戏(PS,Swithc),PC游戏以及手游。其实在这之前,我们团队曾经也制作出深受好评的游戏《像素制作者》等多款游戏。
目前团队的分工如下:
纵观这两年的游戏环境,超休闲游戏迎来了大爆发,经过我们内部讨论,觉得超休闲游戏市场前景还是非常不错的,我们希望制作一款上手非常简单,操作非常傻瓜式的游戏,方便玩家单手握持,单指操作。
所以,我们的游戏必须要有游戏性,虽然操作简单,几分钟一局,但是也要让玩家感到有操作,有难度,还有惊喜,于是《弹无虚发》这个游戏的雏形就诞生了。
《弹无虚发》是我们团队的第一款3D游戏,我们团队包括主美和主程之前都是做的2D游戏,所以各种各样子的幺蛾子也真是层出不穷。其中有个问题真是弄了我们很久,就是游戏在iOS上运行的时候。有的关卡会掉帧到10帧。这个问题的跨度跨越了两周,我们主程羊群做了各种假设,然后论证,然后推翻,最后终于发现是因为我们天空有一个粒子发射器占据了太多了性能,关掉之后终于解决了。
大概2个半月。虽然我们主美之前完全没有做过3D模型,但是多年的美术积累让他上手得非常快,很快就能做成一个栩栩如生的模型。而主程在Cocos的2D方面也有很深的积累,所以上手起来还是很快的。
目前来说,在这个游戏上是不太会变动的。但是我们团队的美术风格也不是仅仅局限于此的,在这之前还有很多游戏的美术风格都是不同的。我可以随便截取几张。
我们大概在CocosCreator3D1.0.0版本发布一周后就开始使用这个引擎了,当时我们也是准备做第一款3D游戏,在想着是用u3d还是c3d好,我们主程是做了5年Cocos的程序员,在他的力荐下,我们使用了CocosCreator3D。
CocosCreator3D相比u3d来说,他首先是免费开源的,这意味着我们的主程可以稍微魔改一下底层代码,其次现在Cocos的社区活跃度也是非常高了,引擎组对开发者的问题反馈都处理得非常快速,而且也没有语言的障碍,这真的是非常好。
对于CocosCreator3D这个引擎来说,才刚发布不到三个月,肯定没法拿成熟的u3d去跟它做对比,但是对于超休闲游戏来说,已经很够用了。希望未来的CocosCreator3D越来越好,争取早日成为国产游戏引擎之光。
方便透露一下游戏接下来的迭代节奏是怎样吗?除了原生平台之外,还会考虑发其他平台吗?比如小游戏和出海?
我,找投资,打钱(* ̄︶ ̄)。
CocosCreator3Dv1.0.3下载
前不久发布的CocosCreator1.0.2版本中正式加入了对OPPO小游戏、vivo小游戏以及华为快游戏平台的支持,在诸多Creator3D制作的小游戏案例中,《猪猪侠:极速狂飙》已上线OPPO小游戏平台。
这款休闲跑酷小游戏,采用了曲面材质效果来使跑酷赛道更加多变有趣,今日,Cocos引擎开发工程师ChiaNing将为各位开发者来解析这种曲面效果的实现思路和方案,在阅读完本文之后,大家便可以将这种效果应用在自己的游戏中。
那么,这样的曲面效果在CocosCreator3D中是如何实现的呢?
要实现曲面的效果,我们有几种方案可选择:
1.直接使用曲面模型
这是最直观最容易想到的实现方案,从模型层面直接将效果做好,省去了其他处理,但这种方案也存在着很多严重的问题:
(1)模型复用不便,模型生成时的状态几乎决定了它的使用场合,这对于游戏开发中需要大量复用资源以减小包体来说有严重的问题。
(2)对于跑酷游戏这种物理需求并不复杂的游戏来说,大部分的游戏逻辑都可以直接通过计算直接完成而并不需要依赖物理引擎实现,对于正常的模型来说,规则的形状对于逻辑实现是很友好的,但是启用曲面模型就会对这种计算带来很多困难,几乎只能通过使用物理引擎来实现,过多的物理计算对性能是会有较大的影响的。
包体不友好,性能不友好,异型模型还会对制作带来麻烦,对于只是为了实现显示效果来说,这些损耗得不偿失。
2.使用材质系统实现
(1)不必使用物理引擎,简单的物理效果可以通过计算来实现,效率更优。
(2)模型可复用,想要实现不同的弯曲效果也很方便,只要使用带有曲面效果的不同参数的材质即可实现同一模型的不同效果。相较于方案一的多重模型来说,只需要几个材质即可解决问题。
(3)参数可配置,可以通过参数调节来得到不同的效果。
分析看来,相较于直接使用曲面模型的方案来说,使用材质系统实现的方案优势很明显,没有额外的开销,也没有太大的包体负担。
综上所述,使用材质系统实现更能满足我们的需求,因此采用材质系统来实现这个效果。
在Shader中,通过顶点着色器即可完成对模型顶点位置的操作。明确了是对顶点位置进行操作后,我们将摄像机所在的点定为原点。
由于我们的摄像机是固定在人物背后,且赛道始终保持向Z轴负方向延伸,所以可以将模型与摄像机的Z轴方向的距离看作函数的输入值,想要得到曲面的效果,模型的点的变化规律如下:
由上述两条规律不难得出,二次函数的变化规律与我们想要实现的曲面效果的规律契合,所以我们的顶点着色器的运算为一个关于顶点位置Z值的二次函数运算。
我们刚刚得出的规律是建立在一个特定空间下的,即以摄像机为原点的空间,这个空间正是空间变换中的观察空间阶段,所以我们之后对顶点的操作正是在这个空间中进行才能够得到正确的结果。
CocosCreator3D提供了完备的材质系统,基于这套材质系统,我们能够很方便地在引擎中创建使用编辑材质,并且在场景预览窗口能够随时观察到材质更改所带来的变化。
Effect资源
此类型资源为符合CocosEffect语法标准的渲染流程描述文件,由YAML格式的流程控制清单和基于GLSL300ES语法的Shader片段共同组成。
Material资源
此资源可看做是Effect资源在场景中的资源实例,其本身除了Effect资源的引用外,还包括很多可配置参数以决定Material的状态。在实际使用中,我们的模型是需要使用Material资源的,这样就可以实现使用同一个Effect但参数不同以实现不同效果的需求了。
材质的使用也非常的方便,在CocosCreator3D编辑器的资源管理器中右键即可新建出Effect资源和Material资源。
在Material资源可选择需要使用的Effect还可对其他参数进行配置,完成配置并保存后,选中需要使用材质的模型,选中需要的material或者直接将material拖入框中即可完成材质的设置。
思路已经很清晰了,那么现在开始着手实现Shader。下面是具体的实现步骤:
1、启动CocosCreator3D编辑器(以下简称编辑器),为实验方便,使用最简单的场景即可,新建场景后,在场景编辑器中新建一个Plane模型,之后以此对象作为查看Shader效果的对象。
2、在编辑器的资源管理器中右键新建Effect,将其命名为curved或者符合要求的名字。
3、这时新建的Effect文件为编辑器内置的Effect模板,其包含了最基础的shader结构,我们需要在这个基础上添加我们需要的功能,关于Effect的具体介绍请参看我们的[材质Effect文档],在里我只对我们需要的更改做出介绍。
4、先来看CCEffect部分:
CCEffect%{techniques:-name:opaquepasses:-vert:general-vs:vert#builtinheaderfrag:unlit-fs:fragproperties:&propsmainTexture:{value:white}mainColor:{value:[1,1,1,1],editor:{type:color}}-name:transparentpasses:-vert:general-vs:vert#builtinheaderfrag:unlit-fs:fragblendState:targets:-blend:trueblendSrc:src_alphablendDst:one_minus_src_alphablendSrcAlpha:src_alphablendDstAlpha:one_minus_src_alphaproperties:*props}%
CCEffect%{techniques:-name:opaquepasses:-vert:unlit-vs:vertfrag:unlit-fs:fragproperties:&propsmainTexture:{value:grey}allOffset:{value:[0,0,0,0]}dist:{value:1}-name:transparentpasses:-vert:unlit-vs:vertfrag:unlit-fs:fragdepthStencilState:depthTest:truedepthWrite:falseblendState:targets:-blend:trueblendSrc:src_alphablendDst:one_minus_src_alphablendDstAlpha:one_minus_src_alphaproperties:*props}%
5、由于默认的Effect模板中使用了内置的顶点着色器,所以这里需要实现自己的顶点着色器,可以参考内置的builtin-unlit的实现来编写此段shader:
uniformConstants{vec4allOffset;floatdist;};
vec4position;CCVertInput(position);highpmat4matWorld;CCGetWorldMatrix(matWorld);
highpvec4vpos=cc_matView*matWorld*position;highpfloatzOff=vpos.z/dist;vpos+=allOffset*zOff*zOff;highpvec4pos=cc_matProj*vpos;v_uv=a_texCoord;returnpos;
6、对于片段着色器,我们并未做特殊的操作,所以直接使用默认提供的就可以。
7、最终的effect如下:
10、似乎上图的效果还不是太直观,所以我用一些建筑模型和一些路面模型简单搭建了一段赛道来模拟游戏可能会出现的场景。建议最好是能够显示出纵深效果的连续模型段,更能显示出效果,当然这个效果并不只限于Y轴方向,还可以同时满足X轴方向的偏置需求,下图所示即为Dist为100,X为-20,Y为-10时的效果图:
希望通过这个简单的案例为各位提供一个了解材质系统的入口。材质系统功能十分丰富,能够实现的效果也是多种多样的,各位快快打开脑洞,用CocosCreator3D来实现各种炫酷的效果吧!
欢迎小伙伴们继续通过论坛、GitHub、Cocos企业服务等渠道向我们提交CocosCreator3D使用反馈!
以上,谢谢!
7月初,CocosCreator3D发布了第一个公测版本,超过千名开发者报名参与公测。为了高效收集测试反馈,集中精力进行产品的迭代和优化,快速推动产品达到功能全面并相对稳定的状态,第一个版本只发放了少量测试资格。经过近2个月的公测,CocosCreator3D取得了飞速的进展,并在8月31日开启了全面公测。在此,向所有参与公测的开发者以及合作立项的重要合作伙伴表示衷心的感谢。CocosCreator3D正式版将在不久与大家见面,敬请期待!
截止目前,已经有不少开发者使用CocosCreator3D制作出了自己的3D游戏,《快上车3D》便是其中之一。
《快上车3D》是采用CocosCreator3D测试版本开发的一款超级刺激的3D休闲小游戏。玩家在游戏中扮演一位快车师傅,控制小车在各种道路上进行移动,接送乘客安全到达目的地,即可过关并获得金币奖励。游戏操作很简单,按住屏幕即可控制小车移动,长按为加速,松开屏幕可以减速。在控制小车行动的过程中,需要时刻注意来往的车辆,及时增减车速,以免发生碰撞车祸,导致任务失败。游戏采用闯关制度,每一个关卡都有不同的订单任务,随着关卡的深入,游戏难度会逐步增加。此外,游戏设置了汽车皮肤系统,使用金币可解锁更多车型和皮肤。
受访者:《快上车3D》团队
编辑整理:C姐
线上版本使用的是CocosCreator3D1.0.0-beta5版本,当时还没有地图编辑器,因此我们的关卡地图都是自己来实现的,也没有图集合并和压缩功能,导致我们的包体比较大,据说CocosCreator3D新版本已经有这些功能,我们也正在接入新版本的引擎。
关卡编辑,我们修改了多次方案。最初的想法是一个场景对应一个关卡,然后关卡编辑人员直接通过CocosCreator3D进行关卡编辑,直接运行便可看到效果。考虑到游戏中有太多的公用元素,如灯光,UI,以及车辆管理等公用节点,我们将地图修改为一个个Prefab,然后使用通用场景,启动时直接实例化该地图预制体。后续发现随着关卡复杂度的提升,直接使用预制体,包体将大幅度增大,因为Prefab会记录大量的节点关系及地图所不需要的信息。
于是,我们决定增加一个地图导出功能,把原有的Prefab下的信息进行解析,解析出有几栋房子,几棵树,几条路,然后分别用的是哪些模型,以及对应的坐标、旋转、缩放等信息进行记录,并将这些信息进行压缩,最后持久化自定义格式的文件中,然后再加载地图时根据动态去创建地图元素。
这样的实现方式,让关卡编辑人员可视化编辑的同时,又不用额外开发关卡编辑器,也解决了包大小的问题。
车辆移动过程中的阴影是实时计算的,可在Scene节点planarShadows组件上配置开启,但目前阴影只能投到某个平面上,平面的位置通过Normal进行配置:
开启阴影支持外,还需要对哪些物体拥有阴影进行设置,即在模型的ModelComponent组件下将ShadowCastingMode设为ON,便会有阴影效果。需要注意的是,目前引擎在一个场景里只支持一个平行光,多个平行光将会没有效果,如果要补光可能要采用其它方式。
首先,创建新的粒子系统,调整对应的粒子参数,如图:
选择TrailMaterial一栏,拖入对应的材质:
双击进入材质编辑,调整对应的材质模式和贴图效果,拖入对应的贴图文件,参数如下:
最后记得保存效果,拖动粒子效果,浏览效果。
发射器选择的是BOX,具体参数如下图:
接着,粒子材质选择放在了ParticleMaterial这一栏:
双击材质,进入材质编辑器,选择对应的材质和贴图模式,如图:
贴图资源根据自己想要的效果去找对应的贴图文件,到这一步尾气效果基本就出来了,记得保存哦。
相对于2D游戏来说,3D能够展现出汽车更多的细节,让玩家更想尽快获得这些车,在一定程度上,提升玩家的游戏动力。
为了能让模型在UI上展示,需要给模型的节点上(即挂载着cc.ModelComponent组件的节点)添加cc.UIModelComponent:
同时要更改车的材质,将材质的Effect修改为builtin-unlit,这样车辆展示在UI上面就不需要受到灯光的影响。
为了实现车辆旋转,可以先将车辆挂到某个空节点下面,空节点先调整好一个展示角度:
之后,只需修改自身的欧拉角y值,便可实现车辆围绕某个视角进行旋转。
为了让撞车时有比较好的表现效果,我们使用了引擎提供的物理引擎:cannon.js,能够拥有比较好的翻转及撞击感,但出于性能上的考量以及游戏自身情况,我们对刚体进行了分组,分成了玩家控制车辆、AI控制车辆、地面等三组,默认情况下,玩家控制的车辆只跟AI控制车辆进行碰撞检测,并且不开启重力影响,只有当玩家车辆与AI车辆碰撞触发时(即那一瞬间),玩家控制的车辆开启重力影响,并且与所有元素开启碰撞检测,然后给车辆一股冲量,这样既能实现物理特性,又能优化性能。
CocosCreator3D延续了CocosCreator2D的UI设计,学习成本比较低,2D所拥有的各类布局神器,widget,layout都有继承过来,开发效率高,适配好,因此我们之前在2D的UI框架设计可以直接沿用过来。
以关卡制作的开发过程为例,关卡制作是《快上车3D》项目开发过程中比较重要的部分,所以这个流程我们做了比较细致的规划:
首先,由策划规划好大致会用到的地图元素,如有多少种路面,多少种树,多少种石头等。
然后,开始并行开发:
最后开始组装,由关卡编辑人员根据之前规划的路线图,使用美术提供的素材,在CocosCreator3D中完成关卡的编辑,最后在游戏中运行起来。
我们团队一直立志于开发休闲小游戏,使用CocosCreator开发的游戏已经有十多款,包括卡牌游戏《五子大作战》、休闲游戏《梦幻甜品》《虫虫向前冲》《分手餐厅》等。之前开发的游戏都是使用2D或2.5D,这次终于可以使用CocosCreator3D引擎提升游戏整体的效果。后续会进一步优化游戏,增加更多的场景、关卡、汽车,也会增加更多3D表现。有了这次的开发经验,我们也会扩充开发的游戏类型,参与开发更多3D游戏。
李清是来自华夏乐游BigRoad工作室的客户端主程,今日他将带来其团队制作的实时竞技小游戏《保卫豆豆-欢乐枪战》的技术实现方案。
1、ECS架构目的:
降低不断增长的代码库的复杂度。
2、游戏原型需求:
3、传统架构的弊端
要实现游戏原型,按照我们之前的做法,是用一个类来实现一种游戏实体的所有功能,这个类既有状态,又有行为。代码复用使用继承来解决。如果用这种做法,那么类大概长这个样子:
在经历过几个项目之后,我们回头反思,发现之前的做法,违反了很多面向对象设计原则。比如说:
4、ECS架构
ECS分别是:
看到实体和组件大家可能觉得比较熟悉,但是这里要注意,这跟我们引擎中的实体组件框架可不是一回事,接下来我为大家简单介绍一下ECS架构的元素。
(1)ECS架构元素:
Component:组件,存储游戏状态
Entity:实体,组件的集合
System:系统,实现游戏行为
World:系统和实体的集合,就是我们的游戏世界,他们的关系大概是这个样子的:
我们可以看到,游戏世界中有很多System,每个System负责实现一种游戏行为,同时有很多组件,每种组件中会有一些游戏状态,实体上可以挂载一个或多个组件,实体和System聚合成了我们的游戏世界。
(2)ECS架构设计:
这个架构有个基础原则:
刚看到这个原则的时候,大家可能会有一些疑问,什么是游戏行为呢?游戏行为,其实就是根据一定的规则去修改游戏状态。比如说移动,就是根据实体的方向和移动速度去改变这个实体的位置。如果系统没有游戏状态,它如何去实现游戏行为呢?
这就是ECS架构最重要的职责了:为系统筛选出它关心的实体子集,只展示给它关心的游戏状态。具体我们是怎么做的呢?
首先把可能单独使用的游戏状态归纳为一个个组件:
比如最常见的位置、方向我们可以归纳为变换组件;移动速度这个组件可能会在移动系统中单独使用,所以我们把它归纳到移动组件中;碰撞组件则有碰撞盒的大小;攻击组件有攻击方向,这样我们就把各种属性给拆开了。
什么是“组件元组”?还是举刚刚移动的例子。移动系统的移动行为,应该是关心实体的位置、方向以及移动速度,就是我们归纳的变换组件和移动组件,那么只要一个实体同时挂载这2个组件,它就可以被移动系统遍历到,系统就会进行操作从而实现移动行为。
最关键的一点,“组件元组”其实就是用来实现框架筛选实体的功能,实体只需要根据自身功能需求挂载相应的组件元组就可以了。比如说子弹它有移动和碰撞的功能,那么就挂载上变换、移动和碰撞这3个组件。
最终实现的效果就是移动系统遍历了英雄和子弹实体,在他们身上实现了移动的行为。攻击系统遍历了英雄和炮台实体,然后他们就可以发射子弹。
(3)ECS架构实例:
接下来,我们看一下比较复杂的碰撞逻辑,这里我们可以对碰撞进行拆解:
首先是碰撞的触发系统。当碰撞发生时将产生一个碰撞事件,然后这个系统只干这件事。剩下的碰撞处理呢,对于子弹来说,会有一个碰撞后销毁系统,它会在碰撞之后把子弹销毁。对于英雄来说,他有一个碰撞后的损血系统,通过这种方式,我们就可以把碰撞进行拆分,再通过刚刚的方式集成在一起。
(4)ECS架构作用:
这种架构可以让每个开发人员负责不同模块的开发,有效地提高多人开发效率。最重要的就是模块的复用,可以便于功能拓展。如果你想改变一个实体的功能,只需要添加或者移除实体的组件就可以了。
比如说:一个英雄死亡之后,他应该失去移动功能,那么在英雄死亡之后,我们只需要把移动组件给移除就可以了,等他复活的时候再给他加回来。可以看到,这种方式非常方便。既然这么方便了,我们就可以做出一个编辑器,把这种能力开放给策划人员。
实际上,暴雪就专门为Overwatch开发了一套Statescript的脚本语言,它用起来就是一个可视化的编辑器,策划人员可以在这个编辑器中编辑每个英雄在各种游戏状态中拥有什么游戏能力,程序只要实现具体的功能模块,然后开放给策划人员使用,非常地灵活。
[参考文档]
1、常见同步机制:
常见的网络同步机制可以分为以下三种:
(1)确定性帧同步
服务端:收集并转发玩家输入数据,不运算游戏逻辑
客户端:在玩家输入数据以后各自运算游戏逻辑
优点:只有玩家输入会被传输,数据流量非常小;代码都是写在客户端上的,所以代码复杂度较低。
(2)快照插值
服务端:运算游戏逻辑,将快照发送给客户端。
快照,就是我这一帧所有游戏实体的游戏状态。
客户端:不运算游戏逻辑,收到快照以后进行差值平滑播放。
实际上,客户端只是一个播放器。
优点:客户端运算量小;断线重连容易实现;游戏逻辑全在服务端,所以非常安全。
缺点:带宽占用非常大。
所以这种方式之前多用于像CS这种局域网对战。
(3)状态同步
服务端:运算游戏逻辑,将玩家输入和部分状态发送给客户端
客户端:在玩家输入时,不等服务器就立马运算游戏逻辑,就有点像单机游戏了,但这种运算结果未经过服务器,不一定是正确的,所以它实际上是一个游戏逻辑的预测。在收到服务器数据后,会对预测结果进行校验,如果错误,就需要平滑地将其纠正到正确的状态。
这里说一下校验的过程,其实就是先回滚再前滚。
优点:客户端可以进行游戏逻辑预测;网络游戏体验好;以服务器数据为准,比较安全。
缺点:代码复杂度高;客户端运算量大;因为有客户端预测,所以客户端之间是不完全同步的。
2、小游戏平台特点
一开始我们的项目采用的是状态同步的方式,但由于我们的项目是针对小游戏平台的,小游戏平台有以下几个特点:
3、欢乐枪战的实现方案
(1)带宽优化
基于小游戏平台的特点,我们项目从状态同步开始做简化,一直简化到以下这种实现方案:
这个大家可能看着就有点眼熟了,其实就是优化了带宽占用的快照插值。这种方案最关键的一点是,你要把带宽优化下来。而带宽优化最关键的,是只有在必要的情况比如游戏开始和断线重连时才发送全量状态,平时玩的过程中,只发送变化的状态。
另外一方面是数据压缩,比如方向,刚开始我们用的是方向向量,但其实用弧度制乘以一千就可以了,这样就把两个Float优化成一个Short。
经过带宽优化成果:
上行:2~15pkg/s,流量占用:0.1KB/s
下行:0~15pkg/s,流量占用:2.5KB/s
这个流量占用对于目前的手机网络来说,是完全可以接受的。
(2)网络抖动优化
介绍完了带宽优化,接下来我们来聊聊网络抖动。
网络抖动指的是,网络的传输是不稳定的,服务端每个逻辑帧会发送一个包,它发送的频率是稳定的,但是对于客户端,可能在一个逻辑帧内收不到包,也可能收到多个包。
这在游戏中的体现就是,玩家在移动过程中,这一帧没有收到包,就停下来了,下一帧收到2个包,就跳过去了,体现出走走停停的状态。
对于这种网络抖动,最常见的优化方法是航位推测法。
航位推测法(DeadReckoning):
用这种方案优化之后,走走停停的现象就基本没有了。
抖动缓存法
这种优化方案关键点在于缓存的大小。如果缓存太小,对于抖动还是比较敏感,抗抖动效果比较弱,缓存太大,玩家的延迟又特别高,所以你需要根据算法动态调整缓存的大小以适应网络环境。
(3)全区全服
(4)分地域部署
我们的项目是实时竞技游戏,对于延迟比较敏感,因此我们的游戏服务器采用了分地域部署。服务器入口使用的是阿里云的“云解析DNS”服务,按照地域自动分配游戏服务器(华北、华东、华南、西南),玩家在进行快速匹配战斗时,会根据地域分配服务器,同一地域玩家进入该地域所属服务器。
[参考资料]
1、Javascript语言使用
CocosCreator上手很容易,不过Javascript语言非常灵活,需要统一代码规范。
所以在项目初期,我们就制定了程序和资源规范,包括代码格式、资源制作标准等,并且会定期去整理代码和资源。
2、客户端性能优化
我们项目一开始用的是CocosCreator1.9版本,用尽毕生所学优化了好几轮,还是只能跑到40多帧。在项目快要上线之际,Cocos推出了2.0Beta4版本,我们就在上线前2周去升级了大版本,现在想想还是挺刺激的。升级之后,体验很流畅,2.0对于性能的提升是非常明显的。
3、自定义裁剪功能
出于对效率的综合考虑,CocosCreator2.0移除了自动裁剪功能(cc.macro.ENABLE_CULLING),所以屏幕外的节点仍然会进行渲染,战斗中drawcall较高。
于是我们就自己实现了一套裁剪功能。
4、Spine换装实现
整体换装:
在《保卫豆豆-欢乐枪战》中,英雄是可以进行整体换肤的,但是Spine官方并不支持一套动画数据对应多套图片。所以我们便开始研究源码,研究后发现,使用SkeletonTexture.setRealTexture()设置为另一张图片资源就可以实现整体整体换装功能了。
局部换装:
《保卫豆豆-欢乐枪战》战斗中,英雄可以自由拾取武器,但是英雄与武器是两套动画文件,具有交叉层叠关系,渲染时需要交叉渲染。这个功能Spine也是不支持的。在研究过源码之后,我们发现使用Slot.setAttachment()设置为另一个动画文件中的附件,就可以实现局部换装功能了。
《保卫豆豆-欢乐枪战》,欢迎各位扫码体验
《乱世王者》是由腾讯天美工作室出品的一款战争策略手游,2017年8月开启测试,数天之内便取得了AppStore中国区畅销榜前三的成绩,充分体现了市场、玩家对于这款游戏的认可。
在尊重和传承经典SLG玩法的前提下,游戏做出了不少突破:游戏构建了一个大世界,为玩家呈现更加真实、热点的游戏环境和生态;游戏融入了极具乐趣的养成模式,使得游戏策略性更强;游戏内加入了如AR寻宝玩法、自定义头像等酷炫新玩法。
腾讯肖程祺
《乱世王者》是基于Cocos2d-x开发的一款战争策略类游戏,需要在游戏玩法创新的同时,在美术品质和性能方面也做出突破。
美术上想要增加细节表现力,让地形的细节更加丰富,同时需要增加3D透视的感觉。
实现成果:
地形细节丰富的代价:
tile数量从100块左右提升到500多块。
渲染压力提升,渲染层数从2层变5层,拖动地图的时候重新组织数据的开销增加。
面临的问题有:
我们的优化方案:
(1)提升贴图的利用率,减少地图纹理的内存
重新排列tile,贴图利用率从50%提升到85%
效果:
5张贴图最终变成3张
(2)建立更高效的数据组织,提升加载效率
地图数据离线处理后,以protobuff的数据格式存储,并使用zstd无损压缩进行处理,加载速度和大小都得到保证。
(3)合并地图批次,减少不必要的顶点数据
每一层地图的都根据TextureID排序后绘制,由于顶点和UV数据已经存储在VBO里面,只需要根据裁剪结果修改indexbuffer即可。5层一共3张贴图,意味着drawcall数量不会超过15个。
(1)处理3D透视关系
所有物件绕x轴旋转至垂直于摄像机的fov中线的角度
(2)解决大物件的视角偏差
边缘位移:
对于皇城和虎牢关这种比较“宽大”建筑单位,在拖动大地图的过程中会出现边缘位移的现象,这个效果也是非常影响美术表现的。
我想到的解决方案是,将大的建筑单位拆分为多个部件,并对每个部件分别作billboard处理。
以皇城为例,考虑到部件之间的关联性,使用了如下的拆分方案,可以看到边缘位移问题得到了很好的解决。
(3)解决3D物体的透视带来的偏差
为了表现更加丰富的怪物细节以及更流畅的动画,美术希望直接在现有的场景中加入3D模型。由于我们改用了3D摄像机,同时大地图场景单位都是2D资源,加入3D模型后两者的透视效果不一致。
在透视相机下,左右移动相机透视问题严重
容易想到的解决方案:先将3D模型按照指定角度渲染到RT上,再将RT上的纹理和其他2D元素一样渲染到场景中。但是Cocos2d-x对多照相机和多渲染路径的支持不够友好,这种方案需要对引擎作比较大的改造,而且会增加更多的显存占用。
我们采用的是第二个方案:在每帧绘制3D模型前,计算出其缩放和位移,然后再使用正投影照相机参数绘制3D模型到屏幕。整个过程只需要一次绘制,没有额外的纹理占用,而且对引擎的改造也是最小的。
渲染3D物体前自己计算正交投影矩阵
《乱世王者》内存中资源的大致占比为:
用4步搞定内存大户:纹理
SmashTexture解决的问题:
(1)SmashTexturePipeline
drawcall的降低
未作任何其他改造的情况下,批量使用smashtexture后,当前界面的drawcall从64降低到了35。
(2)减少IO:BlockCache
现网版本用户行为上报的cache命中率(75%左右)
(3)提升SmashTexture的使用率
原本smashtexture里面的每一行是32像素统一高度,利用率比较低(比如说36像素高的图片,最后还会留下6像素左右的空间。填充到32像素高的smashtexture行里会存在浪费),因此我们按照下图,重新划分了高度行,容纳更多的UI图元。
经过优化,smashtexture的使用率可以从80%提升到90%。
(4)总结SmashTexture的优缺点
优点:
缺点:
提升贴图利用率:改良装箱算法
基于maxrect做优化,将纹理利用率提升到95%+。
提升贴图利用率:减少字号数量
统计了文字的字号使用频率,根据字号分布,我们取了5个字号,其他字号向下取到最接近的预定义字号上,缩放后渲染,大于最大字号的时候则用最大字号放大后渲染。
压缩纹理的一些痛点:
压缩纹理优化:
对于1/2方图的atlas,分离pvrtc的rgb和a通道,合并成方图后,再压缩。
对于不符合2的n次幂的不规则的单图(背景图为主),通过计算出最小的2的n次幂包围尺寸,切分成多块来优化文件大小,解决pvrtc必须方图的不友好设定。
内存管理的小结:
即便SmashTexture,即便Font在一张纹理上,我们还是面临着一些darwcall明显可以优化的地方。
期望的渲染顺序:
通过TriangleCommand底层的dynamicbatching,自动合并批次。
Cocos2d-x的默认渲染顺序:
充分利用TriangleCommand:
利用GlobalZ来解决合批的问题:
新的问题:
当UI有多层弹窗的时候,上下两层的控件由于GlobalZ都从1开始,所以会出问题。
改动最小的解决方案:
改动后的情况:
C姐:Cocos引擎一路走来,收到了很多来自用户的宝贵建议,通过不断的优化和改进,在CocosCreator中,上述许多性能问题都已得到解决。包括:
CocosCreator3D编辑器的强大之处就是可以让开发者快速地制作游戏原型,下面我们将跟随教程制作一款名叫《一步两步》的魔性小游戏。这款游戏非常考验玩家的反应能力,玩家根据路况选择是要跳一步还是跳两步,“一步两步,一步两步,一步一步似爪牙似魔鬼的步伐”。
1.首先启动CocosCreator3D,然后新建一个名为MindYourStep的项目,如果不知道如果创建项目,请阅读[HelloWorld!]
2.新建项目后会看到如下的编辑器界面:
在CocosCreator3D中,游戏场景(Scene)是开发时组织游戏内容的中心,也是呈现给玩家所有游戏内容的载体,游戏场景中一般会包括以下内容:
当玩家运行游戏时,就会载入游戏场景,游戏场景加载后就会自动运行所包含组件的游戏脚本,实现各种各样开发者设置的逻辑功能。所以除了资源以外,游戏场景是一切内容创作的基础。
现在,让我们来新建一个场景。
1.在资源管理器中点击选中asset目录,点击资源管理器左上角的加号按钮,选择文件夹,命名为Scenes。
2.点击先中Scenes目录(下图把一些常用的文件夹都提前创建好了),点击鼠标右键,在弹出的菜单中选择场景文件。
3.我们创建了一个名叫NewScene的场景文件,创建完成后场景文件NewScene的名称会处于编辑状态,将它重命名为Main。
4.双击Main,就会在场景编辑器和层级管理器中打开这个场景。
我们的主角需要在一个由方块(Block)组成的跑道上从屏幕左边向右边移动。我们使用编辑器自带的立方体(Cube)来组成道路。
1.在层级管理器中创建一个立方体(Cube),并命名为Cube。
2.选中Cube,按Ctrl+D来复制出3个Cube。
3.将3个Cube按以下坐标排列:第一个节点位置(0,-1.5,0),第二个节点位置(1,-1.5,0),第三个节点位置(2,-1.5,0)。
效果如下:
首先创建一个名字为Player的空节点,然后在这个空节点下创建名为Body的主角模型节点,为了方便,我们采用编辑器自带的胶囊体模型做为主角模型。
分为两个节点的好处是,我们可以使用脚本控制Player节点来使主角进行水平方向移动,而在Body节点上做一些垂直方向上的动画(比如原地跳起后下落),两者叠加形成一个跳越动画。
将Player节点设置在(0,0,0)位置,使得它能站在第一个方块上。效果如下:
想要主角影响鼠标事件来进行移动,我们就需要编写自定义的脚本。如果您从没写过程序也不用担心,我们会在教程中提供所有需要的代码,只要复制粘贴到正确的位置就可以了,之后这部分工作可以找您的程序员小伙伴来解决。下面让我们开始创建驱动主角行动的脚本吧。
(1)创建脚本
1.如果还没有创建Scripts文件夹,首先在资源管理器中右键点击assets文件夹,选择新建->文件夹,重命名为Scripts。
3.将新建脚本的名字改为PlayerController,双击这个脚本,打开代码编辑器,例如VSCode。
注意:CocosCreator3D中脚本名称就是组件的名称,这个命名是大小写敏感的!如果组件名称的大小写不正确,将无法正确通过名称使用组件!
(2)编写脚本代码
在打开的PlayerController脚本里已经有了预先设置好的一些代码块,如下所示:
import{_decorator,Component}from"cc";const{ccclass,property}=_decorator;@ccclass("PlayerController")exportclassPlayerControllerextendsComponent{/*classmembercouldbedefinedlikethis*///dummy='';/*use`property`decoratorifyourwantthemembertobeserializable*///@property//serializableDummy=0;start(){//Yourinitializationgoeshere.}//update(deltaTime:number){////Yourupdatefunctiongoeshere.//}}
我们在脚本中添加对鼠标事件的监听,然后让Player动起来,将PlayerController中代码做如下修改:
现在我们可以把PlayerController组件添加到主角节点上。在层级管理器中选中Player节点,然后在属性检查器中点击添加组件按钮,选择添加用户脚本组件->PlayerController,为主角节点添加PlayerController组件。
为了能在运行时看到物体,我们需要将场景中的Camera进行一些参数调整,将位置放到(0,0,13),Color设置为(50,90,255,255):
3、添加角色动画
(1)选中场景中的Body节点,编辑器下方控制台边上的动画编辑器,添加Animation组件并创建Clip,命名为oneStep。
(2)进入动画编辑模式,添加position属性轨道,并添加三个关键帧,position值分别为(0,0,0)、(0,0.5,0)、(0,0,0)。
退出动画编辑模式前前记得要保存动画,否则做的动画就白费了。
(3)我们还可以通过资源管理器来创建Clip,下面我们创建一个名为twoStep的Clip并将它添加到Body身上的AnimationComponent上,这里为了录制方便调整了一下面板布局。
(4)进入动画编辑模式,选择并编辑twoStep的clip,类似第2步,添加三个position的关键帧,分别为(0,0,0)、(0,1,0)、(0,0,0)。
(5)在PlayerController组件中引用动画组件,我们需要在代码中根据跳的步数不同来播放不同的动画。
首先需要在PlayerController组件中引用Body身上的AnimationComponent。
@property({type:AnimationComponent})publicBodyAnim:AnimationComponent=null;
然后在属性检查器中将Body身上的AnimationComponent拖到这个变量上。
在跳跃的函数jumpByStep中加入动画播放的代码:
if(step===1){this.BodyAnim.play('oneStep');}elseif(step===2){this.BodyAnim.play('twoStep');}
点击Play按钮,点击鼠标左键、右键,可以看到新的跳跃效果:
为了让游戏有更久的生命力,我们需要一个很长的跑道来让Player在上面一直往右边跑,在场景中复制一堆Cube并编辑位置来组成跑道显然不是一个明智的做法,我们通过脚本完成跑道的自动创建。
一般游戏都会有一个管理器,主要负责整个游戏生命周期的管理,可以将跑道的动态创建代码放到这里。在场景中创建一个名为GameManager的节点,然后在assets/Scripts中创建一个名为GameManager的ts脚本文件,并将它添加到GameManager节点上。
我们将生成跑道的基本元素正方体(Cube)制作成Prefab,之后可以把场景中的三个Cube都删除了。
我们需要一个很长的跑道,理想的方法是能动态增加跑道的长度,这样可以永无止境的跑下去,这里为了方便我们先生成一个固定长度的跑道,跑道长度可以自己定义。跑道上会生成一些坑,跳到坑上就GameOver了。
将GameManager脚本中代码替换成以下代码:
import{_decorator,Component,Prefab,instantiate,Node,CCInteger}from"cc";const{ccclass,property}=_decorator;enumBlockType{BT_NONE,BT_STONE,};@ccclass("GameManager")exportclassGameManagerextendsComponent{@property({type:Prefab})publiccubePrfb:Prefab=null;@property({type:CCInteger})publicroadLength:Number=50;private_road:number[]=[];start(){this.generateRoad();}generateRoad(){this.node.removeAllChildren(true);this._road=[];//startPosthis._road.push(BlockType.BT_STONE);for(leti=1;i 在GameManager的inspector面板中可以通过修改roadLength的值来改变跑道的长度。预览可以看到现在自动生成了跑道,不过因为Camera没有跟随Player移动,所以看不到后面的跑道,我们可以将场景中的Camera设置为Player的子节点。 这样Camera就会跟随Player的移动而移动,现在预览可以从头跑到尾的观察生成的跑道了。 开始菜单是游戏不可或缺的一部分,我们可以在这里加入游戏名称、游戏简介、制作人员等信息。 1、添加一个名为Play的按钮 这个操作生成了一个Canvas节点,一个PlayButton节点和一个Label节点。因为UI组件需要在带有CanvasComponent的父节点下才能显示,所以编辑器在发现目前场景中没有带这个组件的节点时会自动添加一个。 创建按钮后,将Label节点上的cc.LabelComponent的String属性从Button改为Play。 3、增加一个背景框,在StartMenu下新建一个名字为BG的Sprite节点,调节它的位置到PlayButton的上方,设置它的宽高为(200,200),并将它的SpriteFrame设置为internal/default_ui/default_sprite_splash。 4、添加一个名为Title的Label用于开始菜单的标题。 5、修改Title的文字,并调整Title的位置、文字大小、颜色。 6、增加操作的Tips,然后调整PlayButton的位置,一个简单的开始菜单就完成了。 7、增加游戏状态逻辑,一般我们可以将游戏分为三个状态: 使用一个枚举(enum)类型来表示这几个状态。 enumBlockType{BT_NONE,BT_STONE,};enumGameState{GS_INIT,GS_PLAYING,GS_END,}; GameManager脚本中加入表示当前状态的私有变量 private_curState:GameState=GameState.GS_INIT; 为了在开始时不让用户操作角色,而在游戏进行时让用户操作角色,我们需要动态的开启和关闭角色对鼠标消息的监听。所以对PlayerController做如下的修改: start(){//Yourinitializationgoeshere.//systemEvent.on(SystemEvent.EventType.MOUSE_UP,this.onMouseUp,this);}setInputActive(active:boolean){if(active){systemEvent.on(SystemEvent.EventType.MOUSE_UP,this.onMouseUp,this);}else{systemEvent.off(SystemEvent.EventType.MOUSE_UP,this.onMouseUp,this);}} 然后需要在GameManager脚本中引用PlayerController,需要在Inspector中将场景的Player拖入到这个变量中。 @property({type:PlayerController})publicplayerCtrl:PlayerController=null; 为了动态的开启\关闭开启菜单,我们需要在GameManager中引用StartMenu节点,需要在Inspector中将场景的StartMenu拖入到这个变量中。 @property({type:Node})publicstartMenu:Node=null; 增加状态切换代码,并修改GameManger的初始化方法: start(){this.curState=GameState.GS_INIT;}init(){this.startMenu.active=true;this.generateRoad();this.playerCtrl.setInputActive(false);this.playerCtrl.node.setPosition(cc.v3());}setcurState(value:GameState){switch(value){caseGameState.GS_INIT:this.init();break;caseGameState.GS_PLAYING:this.startMenu.active=false;setTimeout(()=>{//直接设置active会直接开始监听鼠标事件,做了一下延迟处理this.playerCtrl.setInputActive(true);},0.1);break;caseGameState.GS_END:break;}this._curState=value;} 8、添加对Play按钮的事件监听。 为了能在点击Play按钮后开始游戏,我们需要对按钮的点击事件做出响应。在GameManager脚本中加入响应按钮点击的代码,在点击后进入游戏的Playing状态: onStartButtonClicked(){this.curState=GameState.GS_PLAYING;} 然后在Play按钮的Inspector上添加ClickEvents的响应函数。 现在预览场景就可以点击Play按钮开始游戏了。 目前游戏角色只是呆呆的往前跑,我们需要添加游戏规则,来让他跑的更有挑战性。 1、角色每一次跳跃结束需要发出消息,并将自己当前所在位置做为参数发出消息。在PlayerController中记录自己跳了多少步: private_curMoveIndex=0;//...jumpByStep(step:number){//...this._curMoveIndex+=step;} 在每次跳跃结束发出消息: onOnceJumpEnd(){this._isMoving=false;this.node.emit('JumpEnd',this._curMoveIndex);} 2.在GameManager中监听角色跳跃结束事件,并根据规则判断输赢,增加失败和结束判断,如果跳到空方块或是超过了最大长度值都结束: checkResult(moveIndex:number){if(moveIndex<=this.roadLength){if(this._road[moveIndex]==BlockType.BT_NONE){//跳到了空方块上this.curState=GameState.GS_INIT;}}else{//跳过了最大长度this.curState=GameState.GS_INIT;}} 监听角色跳跃消息,并调用判断函数: start(){this.curState=GameState.GS_INIT;this.playerCtrl.node.on('JumpEnd',this.onPlayerJumpEnd,this);}//...onPlayerJumpEnd(moveIndex:number){this.checkResult(moveIndex);} 此时预览,会发现重新开始游戏时会有判断出错,是因为我们重新开始时没有重置PlayerController中的_curMoveIndex属性值。所以我们在PlayerController中增加一个reset函数: reset(){this._curMoveIndex=0;} 在GameManager的init函数调用reset来重置PlayerController的属性。 init(){\\...this.playerCtrl.reset();} 我们可以将当前跳的步数显示到界面上,这样在跳跃过程中看着步数的不断增长会十分有成就感。 1、在Canvas下新建一个名为Steps的Label,调整位置、字体大小等属性。 2、在GameManager中引用这个Label: @property({type:LabelComponent})publicstepsLabel:LabelComponent=null; 3、将当前步数数据更新到这个Label中,因为我们现在没有结束界面,游戏结束就跳回开始界面,所以在开始界面要看到上一次跳的步数,因此我们在进入Playing状态时,将步数重置为0。 setcurState(value:GameState){switch(value){caseGameState.GS_INIT:this.init();break;caseGameState.GS_PLAYING:this.startMenu.active=false;this.stepsLabel.string='0';//将步数重置为0setTimeout(()=>{//直接设置active会直接开始监听鼠标事件,做了一下延迟处理this.playerCtrl.setInputActive(true);},0.1);break;caseGameState.GS_END:break;}this._curState=value;} 在响应角色跳跃的函数中,将步数更新到Label控件上 onPlayerJumpEnd(moveIndex:number){this.stepsLabel.string=''+moveIndex;this.checkResult(moveIndex);} 有光的地方就会有影子,光和影构成明暗交错的3D世界。接下来我们为角色加上简单的影子。 (2)在层级管理器中点击最顶部的Scene节点,将planarShadows选项中的Enabled打钩,并修改Distance和Normal参数。 (2)点击Player节点下的Body节点,将cc.ModelComponent下的ShadowCastingMode设置为ON。 此时在场景编辑器中会看到一个阴影面片,预览会发现看不到这个阴影,因为它在模型的正后方,被胶囊体盖住了。 新建场景时默认会添加一个DirctionalLight,由这个平行光计算阴影,所以为了让阴影换个位置显示,我们可以调整这个平行光的方向。 在层级管理器中点击选中MainLight节点,调整Rotation参数为(-10,17,0)。 预览可以看到影子效果: 用胶囊体当主角显的有点寒碜,所以我们花(低)重(预)金(算)制作了一个Cocos主角。 从原始资源导入模型、材质、动画等资源不是本篇基础教程的重点,所以这边直接使用已经导入工程的资源。 在Cocos文件中已经包含了一个名为Cocos的Prefab,将它拖到场景中Player下的Body节点中。 此时会发现模型有些暗,可以加个聚光灯,以突出它锃光瓦亮的脑门。 现在预览可以看到主角初始会有一个待机动画,但是跳跃时还是用这个待机动画会显得很不协调,所以我们在跳跃过程中换成跳跃的动画。在PlayerController类中添加一个引用模型动画的变量: @property({type:SkeletalAnimationComponent})publicCocosAnim:SkeletalAnimationComponent=null; 然后在Inspector中要将Cocos节点拖入这个变量里。在jumpByStep函数中播放跳跃动画 在onOnceJumpEnd函数中让主角变为待机状态,播放待机动画。 onOnceJumpEnd(){this._isMoving=false;this.CocosAnim.play('cocos_anim_idle');this.node.emit('JumpEnd',this._curMoveIndex);} 预览效果: PlayerController.ts GameManager.ts 希望这篇快速入门教程能帮助您了解CocosCreator3D游戏开发流程中的基本概念和工作流程。如果您对编写和学习脚本编程不感兴趣,也可以直接从完成版的项目工程中把写好的脚本复制过来使用。 接下来您还可以继续完善游戏的各方各面,以下是一些推荐的改进方向: Cocos引擎UI全新升级:进一步提升编辑器体验 我就知道你“在看” 点击[阅读原文]进入GitHub仓库! 《弹弹乐》是一款简单的休闲物理弹跳类3D小游戏,用手指轻轻划动屏幕来控制小球运动方向,跳中板心或是板边可获得不同分数,此外,留心获取游戏场景中设置的钻石,可以为玩家增加更多分数。 下图是《弹弹乐》的草图以及整体设计思路: 在理出整体设计思路之后,就可以开始设计每个阶段应该完成的目标,以便于提高代码编写的效率。 以下是我划分的每个阶段的开发任务: 游戏初始化 游戏核心逻辑编写 游戏丰富 初期设计完成后,我们就可以开始整个游戏场景的搭建。 整个游戏一共就一个场景,一个主程序Game,负责管理所有分支管理的Manager以及事件的监听和派发;多个分支Manager,负责管理跳板创建摆放或游戏页面等;一个全局配置模块,负责存储游戏中使用的配置;独立对象的运作脚本,负责自身行为运作。 由于最终呈现出来的详细步骤代码太多,就不一一演示了,今天主要针对每个流程的几个关键部分做个说明。整个游戏的制作流程主要分为以下几点: (1)跳板初始化 跳板初始化主要体现在BoardManager里的initBoard和getNextPos两个方法。在整个游戏过程中,使用的板一共只有5个,后续的跳板生成都是通过复用的方式,不断重新计算位置以及序号。跳板的生成也是严格根据上一个跳板的位置来计算,避免出现长距离位置偏移影响游戏进行。 getNextPos(board:Board,count:number,out:Vec3){constpos:Vec3=outout.set(board.node.position):board.node.position.clone();consto=utils.getDiffCoeff(count,1,2);pos.x=(Math.random()-.5)*Constants.SCENE_MAX_OFFSET_X*o;if(board.type===Constants.BOARD_TYPE.SPRINT){pos.y+=Constants.BOARD_GAP_SPRINT;pos.x=board.node.position.x;}if(board.type===Constants.BOARD_TYPE.SPRING){pos.y+=Constants.BOARD_GAP_SPRING;}else{pos.y+=Constants.BOARD_GAP;}returnpos;}getDiffCoeff(e:number,t:number,a:number){return(a*e+1)/(1*e+((a+1)/t-1));} (2)屏幕事件监听,小球与普通板块弹跳计算 跳板初始化后,开始做小球的弹跳。整个游戏的入口函数都设定在Game类上,Game又添加在Canvas节点上,因此,Game类所挂载的节点作为全局对象的事件监听节点来使用最合适不过。因为主要接受该事件的对象是小球,所以,我们在小球里做监听的回调。 start(){Constants.game.node.on(Node.EventType.TOUCH_START,this.onTouchStart,this);Constants.game.node.on(Node.EventType.TOUCH_END,this.onTouchEnd,this);Constants.game.node.on(Node.EventType.TOUCH_MOVE,this.onTouchMove,this);this.updateBall();this.reset();}onTouchStart(touch:Touch,event:EventTouch){this.isTouch=true;this.touchPosX=touch.getLocation().x;this.movePosX=this.touchPosX;}onTouchMove(touch:Touch,event:EventTouch){this.movePosX=touch.getLocation().x;}onTouchEnd(touch:Touch,event:EventTouch){this.isTouch=false;} 然后,小球根据一定比例的换算来做实际移动距离的计算。在update里每帧根据冲刺等状态对小球进行setPosX,setPosY调整,小球的上升与下降是通过拟重力加速减速来实现。 //ConstantsstaticBALL_JUMP_STEP=[0.8,0.6,0.5,0.4,0.3,0.2,0.15,0.1,0.05,0.03];//正常跳跃步长staticBALL_JUMP_FRAMES=20;//正常跳跃帧数//Ball_tempPos.set(this.node.position);_tempPos.y+=Constants.BALL_JUMP_STEP[Math.floor(this._currJumpFrame/2)];this.node.setPosition(_tempPos); (3)提供相机跟随接口 相机的位置移动不是由自身来操控的,而是根据小球当前的位置来进行实时跟踪。因此,相机只需要调整好设置接口,按照一定脱离距离去跟随小球即可。 update(){_tempPos.set(this.node.position);if(_tempPos.x===this._originPos.x&&_tempPos.y===this._originPos.y){return;}//横向位置误差纠正if(Math.abs(_tempPos.x-this._originPos.x)<=Constants.CAMERA_MOVE_MINI_ERR){_tempPos.x=this._originPos.x;this.setPosition(_tempPos);}else{constx=this._originPos.x-_tempPos.x;_tempPos.x+=x/Constants.CAMERA_MOVE_X_FRAMES;this.setPosition(_tempPos);}_tempPos.set(this.node.position);//纵向位置误差纠正if(Math.abs(_tempPos.y-this._originPos.y)<=Constants.CAMERA_MOVE_MINI_ERR){_tempPos.y=this._originPos.y;this.setPosition(_tempPos);}else{consty=this._originPos.y-_tempPos.y;if(this.preType===Constants.BOARD_TYPE.SPRING){_tempPos.y+=y/Constants.CAMERA_MOVE_Y_FRAMES_SPRING;this.setPosition(_tempPos);}else{_tempPos.y+=y/Constants.CAMERA_MOVE_Y_FRAMES;this.setPosition(_tempPos);}}} 整个游戏的节奏控制其实都是通过小球来的,小球通过弹跳位置决定什么时候开始新板的生成,小球在游戏过程中的得分决定了板子后续生成的丰富性(比如长板或者弹簧板)以及小球的死亡以及复活决定了游戏的状态等等;最后通过UI配合来完成游戏开始结束复活的界面引导交互操作。 (1)跳板复用逻辑编写 保持场景中的跳板就是初始化的数量,所以需要提前度量好板块间的最小距离。那么,屏幕最下方的板块在什么时机开始复用到屏幕最上方呢?举个例子:假设当前场景的板上限是5块,在数组里的顺序就是0-4,按前面说的所有板在全显示的情况下是会均匀分布的,因此,屏幕的分割板就是在中间板的2号板,因此只要超过了2,就代表小球已经跳过的屏幕的一半,这个时候就要开始清理无用的板了。 for(leti=this.currBoardIdx+1;i>=0;i--){constboard=boardList[i];//超过当前跳板应该弹跳高度,开始下降if(this.jumpState===Constants.BALL_JUMP_STATE.FALLDOWN){if(this.currJumpFrame>Constants.PLAYER_MAX_DOWN_FRAMES||this.currBoard.node.position.y-this.node.position.y>Constants.BOARD_GAP+Constants.BOARD_HEIGTH){Constants.game.gameDie();return;}//是否在当前检测的板上if(this.isOnBoard(board)){this.currBoard=board;this.currBoardIdx=i;this.activeCurrBoard();break;}}}//当超过中间板就开始做板复用for(letl=this.currBoardIdx-Constants.BOARD_NEW_INDEX;l>0;l--){this.newBoard();} (2)小球与不同板块弹跳计算 上面的制作过程中,我们已经实现了在普通板上小球是一个乒乓球状态,那么遇到弹簧板或者冲刺板的时候,也可以用类似逻辑结构来继续补充不同板子的不同处理。这里的实现因为结构已定较为简单,就不再多做说明,只需要在全局数据类里加上相应的相同配置即可。 (3)游戏开始与结束逻辑编写 游戏开始以及结束都是通过UI界面来实现。定义一个UIManager管理类来管理当前UI界面,所有的UI打开与关闭都通过此管理类来统一管理,点击事件的响应都直接回调给游戏主循环Game类。 以上部分就基本完成了整个游戏的逻辑部分。 接下来丰富一下游戏的真实表现力。 (1)添加钻石以及吃砖石表现 因为游戏内的跳板数量限制,因此,我们可以大方的给每个跳板配置5个钻石,通过随机概率决定钻石的显示。 if(this.type===Constants.BOARD_TYPE.GIANT){for(leti=0;i<5;i++){this.diamondList[i].active=true;this.hasDiamond=true;}}elseif(this.type===Constants.BOARD_TYPE.NORMAL||this.type===Constants.BOARD_TYPE.DROP){if(Math.random()>.7){this.diamondList[2].active=true;this.hasDiamond=true;}} 既然有了钻石,那吃钻石的时候,肯定也要有些表示,那就是掉落一些粒子来增加表现。由于游戏设计过程中如果有很多对频繁的创建和销毁的话,对性能其实是很不友好的,因此,提供一个对象池在一款游戏中是必不可少。 在这里,我们就可以把散落的粒子存放在对象池里进行复用。在这款游戏的设计过程中,小球部分的计算量是很频繁的,特别是在每帧需要更新的地方,想要去做性能优化的同学可以根据对象池的概念对小球里的一些向量进行复用。 getNode(prefab:Prefab,parent:Node){letname=prefab.data.name;this.dictPrefab[name]=prefab;letnode:Node=null;if(this.dictPool.hasOwnProperty(name)){//已有对应的对象池letpool=this.dictPool[name];if(pool.size()>0){node=pool.get();}else{node=instantiate(prefab);}}else{//没有对应对象池,创建他!letpool=newNodePool();this.dictPool[name]=pool;node=instantiate(prefab);}node.parent=parent;returnnode;}putNode(node:Node){letname=node.name;letpool=null;if(this.dictPool.hasOwnProperty(name)){//已有对应的对象池pool=this.dictPool[name];}else{//没有对应对象池,创建他!pool=newcc.NodePool();this.dictPool[name]=pool;}pool.put(node);} (2)添加跳板表现、增加小球粒子以及拖尾表现 其实这两点功能都基本类似,都是增加一些波动、拖尾粒子等来丰富表现,在这里就不过多说明,具体的表现都写在了Board类和Ball类相对应关键字的方法里。 (3)增加音效和音乐 因为是基础教程,游戏内的表现也不是很多,所以就选取了按钮被点击的音效和背景音乐来做效果。 playSound(play=true){if(!play){this.audioComp.stop();return;}this.audioComp.clip=this.bg;this.audioComp.play();}playClip(){this.audioComp.playOneShot(this.click);}