查看原文
其他

从小游戏到APP,登顶iOS排行榜,这款PVP竞技篮球手游都做了些什么?

蟹老板 COCOS 2022-06-10

2019年,在市场上大部分篮球小游戏都只是「纯投篮」玩法时,一款主打 PVP 实时 1V1 对抗的休闲竞技小游戏《单挑篮球》让玩家眼前一亮,上线微信、QQ、头条等平台后的1年间累积玩家达到 3000W+。



小游戏版的优秀表现坚定了研发团队上线 APP 的信心。经过近一年的打磨,手游版《单挑篮球》日趋成熟、厚积薄发,在近期登上了 AppStore 游戏体育榜第一名。


不管是小游戏版还是原生版,《单挑篮球》都使用了 Cocos Creator 进行开发。原生版和小游戏版有什么不同?都做了哪些改变或优化?本次,《单挑篮球》主程、来自武汉心驰神往(SAGI GAMES)的「蟹老板」,将和我们分享游戏从小游戏到 APP 的「进化」经验与心得。





从小游戏到 APP,绝不仅仅是移植那么简单。在小游戏平台上,「竞技+体育」是个小众品类,优秀产品相对较少,中心化平台提供推荐位导流,好的产品很快便能脱颖而出;而到了原生平台上,如何在竞争激烈的市场中杀出一条血路,是摆在团队面前的一道难题。


着手开发原生版后,为了让游戏有更强的可玩性和更好的视觉效果,项目组由原来的4个人陆续扩充到数十人。技术层面上,我们从 PVP 对战实现与优化、AI 行为树的设计和难度配置、Spine 换装系统的改进及进阶技巧等方面对游戏进行了全面的优化,让手游版《单挑篮球》在原生平台上拥有更高品质表现。


PVP 对战


曾经我们认为在小游戏上做真人对战是个伪命题,吃力不讨好,但当游戏发展到4个2000人群的时候,真人对战的呼声越来越高,让我们重新审视 PVP 这个命题:

  1. 传统 io 类小游戏,由于一局参与人数多,成局困难,即玩即走,没有数值成长,用户粘性低,做 PVP 确实不划算;

  2. 《单挑篮球》主打 1v1 战斗,玩家可以通过分享卡片约战,成局率高,角色有数值成长,有养成要素,做 PVP 完全可行。



由于《单挑篮球》使用 ECS 结构开发,天然支持帧同步,所以我们很快确立了帧同步的方案,具体可参考社区大佬的这篇科普文章:《腾讯高级工程师宝爷:帧同步游戏在技术层面的实现细节》


在小游戏平台上,我们使用了腾讯云联机对战平台(MGOBE)。《单挑篮球》是 MGOBE 早期合作伙伴,早在2019年底 MGOBE 尚未正式发布时,《单挑篮球》就已经在配合腾讯云测试。作为专职开发小游戏十年的中老年程序员,我认为 MGOBE 瑕不掩瑜,确实是小游戏平台上的不二之选,可惜的是 MGOBE 要停止服务了,我们也会接入自己的对战平台。


开始 APP 开发后,我们用 Go 参照 MGOBE 写了一套联机对战平台,API 命名一致,客户端无须做太大改动即可调通。在编写平台时,我们重点关注了以下几个方面:

  1. 分布式加自动弹性部署:当峰值突然陡增的时候,会自动部署新服务器以适应变化,峰值降低后会自动回收降低服务器成本。

  2. 协议压缩:将角色的操作指令用一个字节表示,同时对其他协议也尽可能精简。

  3. 同时支持 tcp 和 udp:由于 udp 有冗余指令,当网络不好时,消息包会积压,对低端手机不太友好,所以针对低端手机连接对战服,仍然使用 tcp 方式。

  4. 详细的日志及监控工具。



对战不同步的问题


由于帧同步完全依赖客户端计算,就要求在游戏过程中两个客户端的演算结果完全一致,不然就会出现画面不同步的情况。


在小游戏版《单挑篮球》中,由于我也是第一次做帧同步,经验不足,初上线的对战版本出现大量不同步的情况,经过排查,大致为以下几种原因:

  1. 浮点数的问题:0.3+0.4=0.7,可0.7-0.4!==0.3,尽管做了取整或保留2位小数的操作,但也总有遗漏。

  2. 入参不一致的问题:为了实现双方角色都以左视角进行游戏,我在对战初始化时,对角色数据作了翻转,对战指令也作了翻转,导致运算中两端的入参其实是不一致的,运算时要取反处理,导致大量浮点数问题。

  3. 战场复位的问题:前一场战斗结束后有些数据未清除干净带入下一场导致不同步。

  4. 运算顺序不一致的问题:谁先碰到球?


解决办法如下:

  1. 逻辑上房主永远在左侧、房客在右侧,但房客端将 UI 进行翻转,以达到视觉上在左侧的要求。

  2. 优化战前及战后的数据清理逻辑。

  3. 检查逻辑上的 bug。


这里还有一个很重要的问题:如何在线上运行的过程中发现玩家出现不同步的情况?


由于帧同步依赖客户端演算,玩家是否出现不同步,服务器是没法知道的,所以不同步的排查只能依靠客户端日志来分析,我的做法是:

  • 每隔一秒,双方各自将本端的战场关键数据组合后加密为 md5 发送给对方检验,如果出现不相等,则各自将自己的日志上报给日志服务器;

  • 日志服务器收到两个日志文件后,会生成一个文本比对页面(类似于 beyond compare),并将链接发送给公司 IM 机器人,通过比对日志条目,来判断不同步的问题在哪里。




重连与追帧处理


重连分为两种情况:

  1. 游戏过程中断线重连。

  2. 游戏异常退出后重连(需恢复战场)。


当客户端收到帧消息后,会将当前帧编号存下来,断线后,由于服务器并不会停止下发帧广播,所以重连后,中间会断帧。此时游戏逻辑不能继续,SDK 必须发送请求补帧接口,将缺失的帧消息取回后插入帧队列,再转发至逻辑层驱动游戏进行,补帧的这一方在画面上会落后另一方,此时需要进行追帧操作,在逻辑层加速演算,将帧队列处理干净,画面自然就追上来了。追帧时,可以在1个 dt 内全部处理完,也可以每 dt 处理几秒的数据,让 CPU 喘口气,画面上能达到快进的效果。


游戏重启追帧与中途追帧流程基本相同,差别在于要恢复战场、且从第一帧追起,对于《单挑篮球》单局1~2分钟的时间来讲,从头追帧也并无太大压力。


PVP 其他经验


  • FPS 帧数:由于网络消息处理也在引擎主循环里排队处理,如果游戏本身性能优化不够好,FPS 过低,会影响到对战质量,建议 FPS 至少>55。

  • AI 介入:游戏中玩家经常会碰到一方因大比分落后而弃赛的情况,就需要 AI 介入,保障玩家体验。因帧同步服务器服务端没有逻辑只做转发,所以 AI 介入目前是由客户端自己实现的。当我端检查到对方掉线后,我端会给对手赋上 AI 行为,AI 的所有操作会以「对方的身份」向服务器发送行动指令,这样能保证对方重连后追帧正常。


AI 行为树


APP 版与小游戏的 AI 开发保持一致,仍然是 BehaviorTree3。


设计思路



角色在场上有三种状态:进攻/防守/均未持球。行为树设计即围绕这三种状态展开:


  • 进攻时,要做什么?

  1. 判断所能做的操作?发技能/上篮/扣篮/突破/投篮(角色使用了有限状态机)。

  2. 判断与篮球的距离(不同的距离出手概率不一样)。

  3. 起跳到什么阶段出手?

  • 防守时,要做什么?

  1. 逼近对手。

  2. 是否抢球。

  3. 对手起跳了,我要不要跟随起跳盖帽?

  • 无球时,要做什么?

  1. 球在空中么?在的话是否争球?

  2. 球在地上么?赶紧去捡吧!


总体来说,行为树的设计类似流程图,把所有的分支设计好就行。


难度控制


在小游戏中,以经典的11分玩法为例,10种难度级别我创建了10个行为树,每个行为树之间的差异极小,主要差别在于 AI 对行为的处理延迟和进入概率。


在 APP 上,策划要求对难度有更细腻的控制。假如有 100级难度,按原来的方法得创建100个行为树……这样显然不现实。解决方案是,在 AI 的每个具体行为的入口上设卡,结合配置表来控制行为的的概率和延迟。


Spine 换装系统



多角色多套装的换装思路


在小游戏平台上,我们参考了  Cocos Creator 例子中的换装实现,即:用一个 spine 的 slot 上的 attachment 去替换另一 spine 上的 slot 上的 attachment。


我们的用法稍有区别,所有的角色及初始皮肤还有套装皮肤都在同一个 spine 文件里,使用时,我先读取当前 spine的getRuntimeData(),获得对应套装 skin 的实时数据,再取套装 slot 中的 attachment 替换当前角色 skin 相同 slot 下的 attachment,太啰嗦了,贴段代码吧:

changeCloth (skinName: string, slotName: string): any {
    let spine: sp.Skeleton = this.node.spine
    let skeletonData = spine.skeletonData.getRuntimeData()
    let skin = skeletonData.findSkin(skinName)
    const slot = spine.findSlot(slotName)
    const slotIndex = skeletonData.findSlotIndex(slotName)
    const attachment = skin.getAttachment(slotIndex, slotName)
    slot.setAttachment(attachment)
  }


但随着角色越来越多,spine 变得越来越难以维护,50个角色+20个套装在一个 spine 里,导出接近需要10分钟,输出的贴图也非常大,而且需要分页。


我想到将50个角色拆成50个 spine,这又带来另一个问题:spine 里有动画,50个 spine 就需要把动画 copy 50次,每新增一个动画就得同步到所有的 spine,美术表示要吐血……


绞尽脑汁后,我想到用组合新皮肤的方式来实现:


  • 将 spine 拆分为三个,base.spine / skin.spine / suit.spine,作用分别是:

  1. base.spine 里有动画定义和基础三色皮肤(黑皮肤/白皮肤/黄皮肤)的四肢,但不带脸部贴图;

  2. skin.spine 里配有角色的脸部和基础外观;

  3. suit.spine 里是套装定义。

  • new sp.spine.Skin 一个 skin 出来,先 copy base.spine 里的肤色皮肤,再从 skin.spine 里取出角色皮肤叠加到 newSkin 中,最后再从 suit.spine 里取出套装部位的 attachment 再一次叠加到 newSkin 中,最终形成一个新的 skin,添加到场上角色的 skins 中,再 setSkin(newSkin),即可产生新的外观。

  • 注意一个前提,三个 spine 的 slot 结构要完全相同,顺序相同,这样覆盖时才不会出现 bug。


贴一段代码片段演示:

export default class SpineUtil {
  static async setSkin (spine: sp.Skeleton, heroId, skinId, collocation = {}) { // posType 1头饰 2上衣 3裤子 4鞋子 5手部 6腿部
    const skeletonData = spine.skeletonData.getRuntimeData()
    const baseClothesData = await AssetLoader.loadResAsync(`spine/clothes/c_${heroId}/c_${heroId}`, sp.SkeletonData)
    const baseClothesDataRuntimeData = baseClothesData.getRuntimeData()
    const baseClothesSkin = baseClothesDataRuntimeData.findSkin('c_' + skinId)
    if (!baseClothesSkin) return

    let newSkinName = 'newSkin' + heroId + skinId
    for (let pos in collocation) {
      newSkinName += '_' + collocation[pos]
    }
    const newSkin = new sp.spine.Skin(newSkinName)
    const { SkinColor } = app.db.actor.GetActorById(heroId)
    const findSkin = skeletonData.findSkin(['white''yellow''black'][SkinColor - 1])
    newSkin.copySkin(findSkin)

    // 使用默认外观
    for (const skinEntry of baseClothesSkin.getAttachments()) {
      const slot = !cc.sys.isNative ? skinEntry.slotIndex : baseClothesSkin.getEntrySlot(skinEntry)
      const name = !cc.sys.isNative ? skinEntry.name : baseClothesSkin.getEntryName(skinEntry)
      const attachment = !cc.sys.isNative ? skinEntry.attachment : skinEntry

      this.addAttachment(SKIN_PART, newSkin, slot, name, attachment)
      this.addAttachment(ARM_PART, newSkin, slot, name, attachment)
    }
    
    ... 省略部分代码
    
    if (skeletonData.skins[skeletonData.skins.length - 1].name === newSkin.name) {
      skeletonData.skins[skeletonData.skins.length - 1] = newSkin
    } else {
      !cc.sys.isNative ? skeletonData.skins.push(newSkin) : skeletonData.addSkin(newSkin)
    }

    spine.setSkin(newSkinName)
    }
}


大家可能注意到这里 native 的 api 跟 js 的不一样,是因为 native 里有些方法和属性没有导出,比如 new sp.spine.Skin 在 native 上是会报错的,所以我们又修改了一下 spine c++ 下的 spine 运行库。


Spine 的一些其他技巧


  • 利用空 bone 实现角色投篮的出手点。在游戏中取出出手点的世界坐标作为球飞出的起点,就可以由美术来控制,不必开发写死一个不直观的坐标,见图:
  • 利用多轨道播放来实现角色个性皮肤上的局部特效表现。

  • 利用缩放来表现角色的身高差异或适应不同界面的展示需要。


Cocos Creator 3.6 将对 Spine 性能及换装进行全面优化,该版本预计将在今年年中发布。by 引擎组




好了,暂时写到这里了,再写下去真得毕业了(其实是 C 姐催稿了),后续若有时间精力,会再补充一些开发经验分享到论坛,欢迎到论坛专贴一起交流:

https://forum.cocos.org/t/topic/134257


本文为「Cocos 中文社区第4期征稿活动」参与作品。投稿现已截止,感谢小伙伴们的热情参与!本期征稿我们增设了「人气作品」评选,点击文末【阅读原文】前往论坛,给你喜欢的作品投上一票吧!


往期精彩

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存