深度解析:基于 Pixi 渲染引擎打造沉浸式「滑雪竞技」小游戏!
导读
2024 年元旦期间,快手推出了一款名为“驭雪冲锋赛”的滑雪竞技游戏,该游戏以 Pixi 渲染引擎为基础,通过精心设计的多样化玩法,为用户带来有趣的社交互动体验。
整个项目由三大核心板块组成:游戏首页、滑雪竞技和许愿星空。在滑雪冲关的过程中,玩家们不仅可以体验到速度与激情的碰撞,还能收获诸多精心设计的道具和奖励,例如“烟雾弹”、“横冲直撞”、许愿烟花、头像挂件以及现金红包等。本文将从前端开发的角度,重点关注滑雪游戏部分在开发过程中遇到的技术挑战以及相应的解决策略。在这个环节中,技术团队面临了众多难题,包括如何确保游戏在多种设备上的流畅运行、如何优化 Pixi 渲染引擎以提供更为逼真的滑雪体验、以及如何实现与后端服务器的稳定通信等……
全文共 8912 字,预计阅读时间 20 分钟。
一、项目背景
2024 年元旦期间,快手推出了一款基于 Pixi 渲染引擎的滑雪竞技游戏"驭雪冲锋赛",该项目通过丰富多样的玩法设计,为用户带去更加有趣的社交体验。项目包括首页、滑雪竞技和许愿星空三大板块。用户在滑雪冲关的过程中,可获得各种道具和奖励,如“烟雾弹”、“横冲直撞”、许愿烟花、头像挂件以及现金红包等。使用“烟雾弹”可对好友投掷烟雾干扰;使用“横冲直撞”,可以在滑行过程中无视障碍,快速滑行;而许愿烟花则可在许愿星空中许下新年心愿。本文将从前端开发的视角,重点探讨该项目中滑雪游戏部分在开发中遇到的技术难点及相关解决思路。
二、游戏现实
01 游戏玩法介绍
滑雪游戏一共分为 6 个关卡,每个关卡都配置了不同的滑行速度、目标滑行距离、障碍物布局以及获得奖品的概率,其中第 6 个关卡为无限火力关卡,此关卡不设置目标滑行距离,主要是提供给玩家用于刷新排名。游戏玩法的核心在于玩家手势操控的灵活度,玩家需要通过左右滑动来控制角色的滑动方向以此来与障碍物产生交互。障碍物主要分为三类:石头、礼盒和火箭:
碰到石头:非横冲直撞模式下碰到石头会导致游戏结束,需要使用复活卡才能继续游戏或者重头开始玩本关;
碰到礼盒:礼盒打开获得随机奖励(心愿烟花、头像挂件、烟雾弹或者什么都没有)
碰到火箭:开启一定时限(比如 10s)的横冲直撞模式,此模式下可以畅通无阻,并且获得的奖励会正常叠加。
当玩家进入无限火力关卡后,可以根据活动的奖励规则(在活动结算周期内排名全服榜前 3000 名的玩家可以均分十万元大奖)继续滑行来刷新排名。
02 游戏引擎选择
滑雪游戏涉及手势交互、角色移动、碰撞效果等多种复杂动画交互,因此在技术选型时需重点考虑上手难度、应用场景和性能三个方面因素。经过对 Three.js、Cocos 和 Pixi 等引擎的详细对比分析,我们最终确定采用 Pixi 作为游戏的核心渲染引擎。
03 游戏分层实现
滑雪游戏的界面主要由两个部分构成:基于 Vue.js 实现的 UI 层,以及使用 Pixi 引擎制作的游戏层。其中,UI 层负责展示与游戏交互核心无关的内容,比如顶部里程进度、关卡提示、获得的道具以及各种动效元素;而游戏层负责游戏引擎的渲染更新、游戏角色的布局与交互等核心游戏元素。两个层级之间通过事件派发的方式进行数据和交互的通信。整体的分层结构如图所示:
在游戏层内部,又可进一步划分为五个子层级:背景层、道具 &障碍物层、角色层、道具效果层(如烟雾弹效果)以及顶部蒙层。从最底层的背景层到最上层的顶部蒙层层级依次升高。接下来,我们将按照层级由低到高的顺序,分别详细介绍这些游戏子层级的具体实现。
(1)背景层
从视觉效果上来看,游戏中的角色需要不断向前滑行。基于相对运动的原理,只需保持角色在 Y 轴上的坐标保持不变,同时向上移动背景图,就能产生角色前滑的视觉效果。具体的实现思路是:将一张大背景图等分为 6 张小图,然后循环拼接并向上移动这些小图。当第一张背景图移出屏幕外后,将其 Y 轴坐标进行调整,重新拼接到最后一张小图的位置,从而形成无缝循环滚动的背景效果。
步骤一:
初始化一次性 load 6 张图片,将 6 张图片的纹理写入一个数组;
计算每个设备在视口内需要多少张图片(设备高度除以每张图片的高度后加 1);
设置每张图片的 Y 坐标并将图片放置到背景层 container 里面以显示
步骤二:判断到达临界点
步骤三:替换图片
取出第一个精灵,改变其纹理,设置 Y 轴坐标,将它塞到最后
图示
最终背景层效果如下所示:
,时长 00:12
(2)道具、障碍物层
在道具与障碍物层的实现过程中,需要重点解决以下几个关键问题:障碍物实例的动态创建,撞击时的视觉动效处理,以及画布上障碍物节点过多导致的内存占用和性能问题。
① 障碍物实例创建
动效效果:
礼盒 &火箭上下漂浮效果
示意效果:
实现方式:
礼盒和火箭的漂浮效果,使用缓动动画实现,设置盒子在每一帧进行循环上下移动,采用 TWEEN 动画库,实现动画效果.
⚠️设置贝塞尔曲线的时候,可以把默认的 ease 设置为 none
yoyo(true):设置往返循环动画。
动效效果:
礼盒 &火箭发光效果
示意效果:
实现方式:
礼盒周边的发光效果,采用两段缓动动画实现,设置光圈静态图片的 scale 属性和 opacity 属性,通过两个光圈实例的动画时间差实现礼盒周边发光效果。
(1)初始化光圈元素,设置光圈初始化属性,并添加到画布中。
(2)实现光圈缩放、透明度动画。
(3)通过 delay 属性设置第二个光圈实例动画的延迟时间为第一个光圈实例播放 850ms 之后开始播放。
动效效果:
礼盒碰撞后打开效果
示意效果:
实现方式:
使用序列帧实现。
将设计输出的序列帧使用 AnimatedSprite 库,设置相关参数,实现动画的播放。
监听动画播放完成事件,动画播放完成,销毁 AnimatedSprite 元素。
体积优化:
将序列帧做成雪碧图,减少资源体积,优化动画加载效果。
② 性能优化措施
障碍物缓存池:复用纹理实例、减少图形处理中的延迟,提高图形渲染的效率和性能;在障碍物使用完毕销毁的同时,设置缓存池,用于缓存障碍物纹理,若缓存池中无此纹理,将此纹理添加到缓存池当中,并重置纹理状态为初始状态,当创建新的障碍物纹理时,优先从缓存池中寻找是否有满足条件的障碍物纹理,若存在直接使用缓存池的纹理作为新障碍物纹理,设置对应的纹理属性为新的障碍物属性。
序列帧改为缓动动画:减少内存占用,降级包体积。
实例及时销毁:销毁不再需要的实例,及时释放资源;每次渲染新的障碍物前,判断已渲染到画布的障碍物所在的位置是否在非视口内且玩家已经滑过的距离范围内,移除并销毁掉障碍物实例释放内存。
(3)角色层与游戏控制
角色层主要包括游戏角色的制作与游戏角色的控制。
① 游戏角色制作
游戏角色有两套皮肤,每套皮肤有 6 组动作(向前滑行、向左摔倒、向右摔倒、坐火箭、左转身、右转身),所有动作都使用序列帧制作完成,其中左转身和右转身、左摔倒和右摔倒用同一套序列帧,镜像而成。Pixi 通过 AnimatedSprite 播放序列帧。
默认皮肤序列帧示意
红色皮肤序列帧示意
② 游戏角色控制
角色控制是指玩家通过左右滑动的方式改变游戏角色的 X 轴坐标,固定游戏角色的 Y 轴坐标,从而控制角色在游戏中的移动。难点主要在于左右跑道的切换。
角色控制是指玩家通过左右滑动的方式改变游戏角色的 X 轴坐标,固定游戏角色的 Y 轴坐标,从而控制角色在游戏中的移动。难点主要在于左右跑道的切换。
主要实现思路是当游戏处理进行中状态时,监听页面的 touchstart 和 touchend 事件:
手指触摸屏幕时,记录手指触摸位置在屏幕中 X 轴的位置 slideStartX
手指离开屏幕时,记录手指触摸位置在屏幕中 X 轴的位置 slideEndX,计算差值 diffX = slideEndX - slideStartX
如果 diffX > 0,表示向右滑(如果当前滑动方向本身就是向右,则不做处理)
如果 diffX < 0,表示向左滑(如果当前滑动方向本身就是向左,则不做处理)
滑动时,通过 tweenjs 缓动函数将玩家移动到左/右的固定位置。
(4)碰撞检测
在本次活动的游戏中,碰撞主要分为两类:障碍物碰撞和路过好友。
① 障碍物碰撞
障碍物分为三类:礼盒、火箭和石头
碰到石头:游戏结束,需用复活卡继续游戏或者重玩本关
碰到礼盒:打开礼盒,可能飞出心愿烟花(4 种)、头像挂件(3 种)、烟雾弹、空
碰到火箭:开启横冲直撞模式,碰到石头不会导致游戏结束
实现步骤:
确定碰撞热区。由于障碍物的形状不规则,为了保证用户视觉上能感受到确实撞上了,需要约定一下碰撞的热区(备注:玩家热区应该是 53*53,设计稿标注未及时更新)
获取玩家和障碍物的坐标。
在构建玩家类时,会定义一个方法获取玩家的坐标信息,由于玩家容器的位置是以中心点为基准的,因此在水平方向的左边界 x1 和右边界 x2 会在中心点 x 的基础上加/减去一半的热区宽度(26.5 = 53 / 2)
摆放障碍物时,将障碍物在画布上的实时位置坐标作为障碍物实例的必须属性,即_X 和_Y。障碍物容器以左上角的的位置为基准,计算方式跟玩家的有差异。
碰撞计算。获得了玩家与障碍物的坐标之后,就可以进行碰撞计算了(判断是否有交叉即可)。
玩家的坐标为:
玩家热区的中心点:x
玩家热区的左边界:x1
玩家热区的右边界:x2
玩家热区的下边界:y + this.currentRoundDistance
备注:由于玩家实际上只在水平方向移动,y 值初始化之后一直是不变的,纵向上是画布在移动,因此做碰撞检测时,为了保证玩家和障碍物的参照物一样,需要将玩家的 y 加上当前关卡画布移动的距离,也就是 this.currentRoundDistance
障碍物的坐标为:
障碍物热区的左边界:item._X
障碍物热区的右边界:item._X + item._width(热区的宽度,非实际宽度)
障碍物热区的上边界:item._Y
障碍物热区的下边界:item._Y + item._height(热区的高度,非实际高度)
那么,判断是否有交叉
通过以上的步骤,已经能够判断出是否产生碰撞了,但还是存在一些多余的计算,因此需要做下优化。进行碰撞检测时,需要遍历画布可视区上的障碍物列表,那么可考虑的优化点有三个:
只判断离玩家最近的障碍物即可。(这一步其实障碍物列表已经做了,因为障碍物列表就是根据障碍物的摆放顺序返回的)
只遍历某一侧的障碍物即可。(如果我们能确定玩家已经位于左边或者右边的滑道,那么只需要计算一侧的就可以,可以新增一个校验条件)
同一个障碍物检测一次即可。由于碰撞检测函数是在 ticker 中执行的,执行会非常频繁,所以会存在一个 ticker 的时间内同一个障碍物被反复计算的问题,因此在定义障碍物的数据结构时,新增了 isNeedCollision 的标识(默认为 true),如果障碍物产生碰撞,那么 isNeedCollision 会置为 false,下次遍历时会过滤掉这个障碍物
具体实现:
② 路过好友
是否路过好友相比障碍物的碰撞判断要简单,我们只需要判断纵向方向上是否有交叉即可
好友的坐标为:
好友的上边界:item.y
好友的下边界:item.y + item.height
判断是否有交叉:item.y <= y + this.currentRoundDistance <= item.y + item.height
具体实现:
04 游戏状态管理
在游戏开发中,游戏状态管理逻辑用于跟踪游戏的当前状态和目标状态之间的转换,包括游戏主菜单的控制,游戏中,游戏暂停,游戏死亡,游戏过关等状态。
状态定义:首先需要明确定义游戏中可能存在的各种状态,比如主菜单、游戏中、暂停、胜利、失败等。
状态切换:确定触发状态切换的条件,如玩家点击开始游戏按钮会从主菜单状态切换到游戏中所经历的状态,游戏过关会从游戏中状态切换到胜利状态等。
状态处理:针对每个状态,需要编写处理逻辑,比如在游戏中状态需要处理输入、更新游戏对象的状态、渲染游戏画面等。
状态堆栈:有时游戏可能需要支持多个状态的叠加,比如在游戏中状态中打开菜单,这就需要维护一个状态堆栈以正确处理叠加状态之间的转换和交互。
状态持久化:有些游戏可能需要在玩家离开游戏时保存当前状态,以便下次继续游戏,因此需要考虑状态的持久化和恢复逻辑。
本次滑雪游戏基于以上 5 个管理逻辑的设计,游戏的状态管理设计为 5 个层级进行调度,UI 效果层、用户交互层、事件处理层、游戏层、数据管理层。
(1)游戏状态机设计
设置统一的方案管理游戏状态的变化,主要状态转换逻辑如下图所示。
(2)游戏通信设计
游戏实例和用户交互层采用事件总线(eventBus)的方式进行通信。
游戏实例和 ui 效果层采用 props / $emit 方式进行通信。
数据管理层采用 store 状态管理维护游戏数据。
数据层主要用户处理游戏数据,核心包括障碍物数据的处理,和游戏相关配置处理。
游戏数据兜底数据处理:滑雪游戏中,游戏启动后,没一帧的屏幕刷新都会产生游戏画布的渲染以及游戏状态的更新,因此游戏相关的数据时效性对游戏的状态影响较大。数据的时效性主要是障碍物数据的接口请求延迟和数据的更新延迟。
存在的问题:
(1)请求数据接口的延迟导致玩家在玩的过程中会存在障碍物数据和玩家所在位置的游戏数据不匹配的问题
(2)玩家经过的路段无对应的障碍物数据。
数据处理方案:数据预缓存和兜底数据。
数据预缓存:开启游戏的时候,设置一个障碍物缓存数据,并设置缓存区大小的最小阈值,在游戏中,不断的从缓存区中取障碍物数据进行渲染,并检测当前缓存区的障碍物数据是否小于最小阈值,当小于最小阈值时,通知数据接口请求数据并补充到缓存区中。
最小阈值(minSize)的设置逻辑:因不同关卡的玩家速度是不同的,因此在接口响应时间(RequestTime)内,可经过的障碍数据是不同的,因此需要将缓存区缓存的障碍物数据大小与速度相关联,根据不同的速度动态调整缓存区的大小,除此之外,在滑行过程中会存在横冲直撞的道具使用会在常规速度(Speed)基础上进行加成,因此我们以玩家实时的最大加成速度(BufferSpeedRatio)作为计算最小阈值的变量。
兜底数据:本地存储对于每个关卡设置一份中难度的兜底数据作为接口请求超时或者接口请求异常的兜底数据(兜底数据仅包含石头),并设置障碍物接口请求的超时时间,本次项目设置的超时时间为 2s(可根据接口的实际耗时时间进行调整)。
我们本次的障碍物数据返回格式是每次返回一段跑道的障碍物数据,数据形式如下:
我们设计的兜底数据是 50 个格子的障碍物数据。(index:0-50),每次接口请求成功时,保存当前已有数据的最后一个数据的位置(lastDataEndIndex),当接口请求异常或者超时时采用兜底数据时,在兜底数据中每个障碍物的纵向位置上加上 lastDataEndIndex,Mock 为请求的路段数据添加到缓存区中。
格子:在本次滑雪中,为了方便根据策略摆放障碍物,我们根据每个障碍物的尺寸将游戏画布划分为多行两列的格子布局,每个格子可摆放一个障碍物。根据摆放策略将下发的障碍物摆放到指定的位置上。相互换算:1 格子=30 米=120px
三、总结
本文主要阐述了基于 Pixi 引擎搭建类 3D 游戏的全过程,包括从零开始的实践历程以及在此过程中遇到的技术挑战及解决方案。与此同时,我们一直在探索更加趣味多样的游戏玩法,期待能与更多同学一起讨论、研究并分享前端游戏的应用实现、优化手段以及底层原理。
本文作者:阮叶丽
版权声明: 本文为 InfoQ 作者【快手技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/1af63b3e46d9743b3231e09f0】。文章转载请联系作者。
评论