从小游戏到APP,分享《单挑篮球》进阶之路

从小游戏到APP,分享《单挑篮球》进阶之路

文章正式开始之前,按惯例,还是先挖个坟:

《单挑篮球》开发过程文章发布当月,产品就在手Q小游戏平台获得了多次头部推荐,1年时间内累计玩家3000w+,同时也获得过抖音平台推荐位和入围首批微信小游戏优选计划,应该说成绩还不错,后续也推出了PVP真人对战,反响强烈,即便是在停更后很长一段时间,玩家群里依然热情不减,这也让我们对上线APP这件事增强了信心。

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

为了让游戏有更强的可玩性和更好的视觉效果,项目组由原来的4个人,陆续扩充到数十人。经过一年多的打磨,《单挑篮球》在 AppStore 取得了体育榜第一的成绩。这中间的过程并不轻松。


公众号二维码

公众号:Basketball_Duel

好了,广告发了,NB也吹完了,下面开始写干货了,我会围绕着:“在APP平台上,我们都有哪些设计上的改进”来写:

  1. PVP对战实现
  2. AI行为树的设计和难度可配置
  3. Spine换装系统的改进和使用技巧分享
  4. … … (时间精力允许再补充)

PVP对战实现

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

  1. 传统io类小游戏,由于一局参与人数多,成局困难,即玩即走,没有数值成长,用户粘性低,做PVP确实不划算。
  2. 单挑篮球主打1v1战斗,玩家可以通过分享卡片约战,成局率高,角色有数值成长,有养成要素,做PVP完全可行。

由于单挑篮球使用ECS结构开发,天然支持帧同步,所以我们确立了帧同步的方案,论坛有一篇大佬的科普文章,借花献佛:

帧同步游戏在技术层面的实现细节

在小游戏平台上,我们使用了腾讯云的联机对战平台(就是Creator工程模板里集成的那个MGOBE对战平台)。《单挑篮球》是 MGOBE 早期合作伙伴,早在2019年底 MGOBE 尚未正式发布时,《单挑篮球》就已经在配合腾讯云测试。我来说说使用感受吧:

优点:

  1. 接入简单,API设计合理易于掌握。
  2. 无须配备服务器开发和运维,节约人力成本。

缺点:

  1. 通用性强的框架,必然要牺牲效能,协议没有压缩,大量使用string类型,导致帧消息偏大,对篮球这种高频操作的游戏不友好。
  2. 服务器没有CDN加速或者说多节点选择,在网络高峰期延迟较大。
  3. 没有自己的服务器,就意味着无法控制服务器的负载,出问题的时候要发工单,太误事。

瑕不掩瑜,作为专职开发小游戏十年的中老年程序员,我认为MGOBE确实是小游戏平台上的不二之选,可惜的是 MGOBE 要停止服务了。我们也会接入自己的对战平台。

开始APP开发后,我们用Go参照MGOBE写了一套联机对战平台,API命名一致,客户端无须做太大改动即可调通,我们平台的主要优点在于:

  1. 分布式加自动弹性部署:当峰值突然陡增的时候,会自动部署新服务器以适应变化,峰值降低后会自动回收降低服务器成本。
  2. 协议压缩:将角色的操作指令用一个字节表示,同时对其他协议也尽可能精简。
  3. 同时支持tcp和udp:由于udp有冗余指令,当网络不好时,消息包会积压,对低端手机不太友好,所以针对低端手机连接对战服,仍然使用tcp方式。
  4. 详细的日志及监控工具。

PVP对战质量处理经验

对战不同步的问题

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

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

  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其他经验:

  1. 由于网络消息处理也在引擎主循环里排队处理,如果游戏本身性能优化不够好,FPS过低,会影响到对战质量,建议FPS至少>55
  2. 游戏中玩家经常会碰到一方因大比分落后而弃赛的情况,就需要AI介入,保障玩家体验,因帧同步服务器服务端没有逻辑只做转发,所以AI介入目前是由客户端自己实现的,原理是:
  3. 当我端检查到对方掉线后,我端会给对手赋上AI行为,AI的所有操作会以“对方的身份”向服务器发送行动指令,这样能保证对方重连后追帧正常。

AI行为树的设计思路及难度控制

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

设计思路

冒着被毕业的风险,我贴一张高清的行为树图片出现讲解,以经典的11分玩法为例,我们先拆解一下行为树结构:

  1. 角色在场上有三种状态:进攻/防守/均未持球。
    1. 进攻时,要做什么:
      1. 判断所能做的操作?发技能/上篮/扣篮/突破/投篮(角色使用了有限状态机)。
      2. 判断与篮球的距离(不同的距离出手概率不一样)。
      3. 起跳到什么阶段出手?
  2. 防守时,要做什么?
    1. 逼近对手。
    2. 是否抢球。
    3. 对手起跳了,我要不要跟随起跳盖帽?
  3. 无球时,要做什么?
    1. 球在空中么?在的话是否争球?
    2. 球在地上么?赶紧去捡吧!

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

难度控制

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

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

Spine换装系统的改进及进阶技巧

多角色多套装的换装思路

在小游戏平台上,我们参考了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就需要把动画copy50次,每新增一个动画就得同步到所有的spine,美术表示要吐血… …

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

  1. 将 spine 拆分为三个,base.spine / skin.spine / suit.spine,作用分别是:
    1. base.spine 里有动画定义和基础三色皮肤(黑皮肤/白皮肤/黄皮肤)的四肢,但不带脸部贴图。
    2. skin.spine 里配有角色的脸部和基础外观。
    3. suit.spine 里是套装定义。
  2. new sp.spine.Skin 一个 skin 出来,先copy base.spine里的肤色皮肤,再从skin.spine里取出角色皮肤叠加到newSkin中,最后再从suit.spine里取出套装部位的attachment再一次叠加到newSkin中,最终形成一个新的skin,添加到场上角色的skins中,再setSkin(newSkin),即可产生新的外观。
  3. 注意一个前提,三个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 运行库,如果大家能把我推到第一的位置,我就把修改的部分发出来(我太想要iWatch啦啦啦)。

Spine 的一些其他技巧

  1. 利用空 bone 实现角色投篮的出手点,在游戏中取出出手点的世界坐标作为球飞出的起点,就可以由美术来控制,不必开发写死一个不直观的坐标,见图:

  1. 利用多轨道播放来实现角色个性皮肤上的局部特效表现。
  2. 利用缩放来表现角色的身高差异或适应不同界面的展示需要。

好了,暂时写到这里了,再写下去,真得毕业了(其实是C姐催稿了),后续若有时间精力,会再补充一些开发经验分享上去。

17赞

为大佬点赞!

可想而已spine 对游戏是多重要3.x 到现在还不能换装我都是服了 2.x版本都可以换

@Cocos-Cjie

关于 spine 性能以及换装我们都尽可能安排在 3.6 处理了哈,给你造成的不便非常抱歉

给大佬点赞!

给大佬点赞!

给大佬点赞!

给大佬点赞!

给大佬点赞!

给大佬点赞

mark!!

creator3.8版本 运行new sp.spine.Skin时 报错:Cannot construct Skin due to unbound types: N5spine6StringE。 有没有一样的人, 怎么解决?