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

您所在的位置:网站首页 篮球实用技巧论坛网站 从小游戏到APP,分享《单挑篮球》进阶之路

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

2023-07-31 21:56| 来源: 网络整理| 查看: 265

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

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

小游戏《单挑篮球》开发过程分享 - Showcase - Cocos中文社区 微信小游戏从立项到上线!谈谈《猎头专家》的开发历程

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

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

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

appStoreappStore750×667 244 KB 公众号二维码 公众号截图公众号截图750×1334 101 KB

公众号:Basketball_Duel

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

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

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

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

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

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

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

优点: 接入简单,API设计合理易于掌握。 无须配备服务器开发和运维,节约人力成本。 缺点: 通用性强的框架,必然要牺牲效能,协议没有压缩,大量使用string类型,导致帧消息偏大,对篮球这种高频操作的游戏不友好。 服务器没有CDN加速或者说多节点选择,在网络高峰期延迟较大。 没有自己的服务器,就意味着无法控制服务器的负载,出问题的时候要发工单,太误事。

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

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

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

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

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

浮点数问题:0.3+0.4=0.7,可0.7-0.4!==0.3,尽管做了取整或保留2位小数的操作,但也总有遗漏。 入参不一致问题:为了实现双方角色都以左视角进行游戏,我在对战初始化时,对角色数据作了翻转,对战指令也作了翻转,导致运算中两端的入参其实是不一致的,运算时要取反处理,导致大量浮点数问题。 战场复位的问题:前一场战斗结束后有些数据未清除干净带入下一场导致不同步。 运算顺序不一致的问题:谁先碰到球? 解决办法: 逻辑上房主永远在左侧,房客在右侧,但房客端将UI进行翻转,以达到视觉上在左侧的要求。 优化战前及战后的数据清理逻辑。 检查逻辑上的bug。 如何在线上运行的过程中发现玩家出现不同步的情况?

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

每隔一秒,双方各自将本端的战场关键数据组合后加密为md5发送给对方检验,如果出现不相等,则各自将自己的日志上报给日志服务器,服务器收到两个日志文件后,会生成一个文本比对页面(类似于beyond compare),并将链接发送给公司IM机器人,通过比对日志条目,来判断不同步的问题在哪里。

不同步不同步944×905 223 KB 重连与追帧处理

重连分为两种情况:

游戏过程中断线重连。 游戏异常退出后重连(需恢复战场)。

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

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

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

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

设计思路

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

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

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

难度控制

在小游戏中,以经典的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,美术表示要吐血… …

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

将 spine 拆分为三个,base.spine / skin.spine / suit.spine,作用分别是: base.spine 里有动画定义和基础三色皮肤(黑皮肤/白皮肤/黄皮肤)的四肢,但不带脸部贴图。 skin.spine 里配有角色的脸部和基础外观。 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 运行库,如果大家能把我推到第一的位置,我就把修改的部分发出来(我太想要iWatch啦啦啦)。

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

94e2426c-715b-4355-8a9d-55588c05d25594e2426c-715b-4355-8a9d-55588c05d255584×1092 142 KB 利用多轨道播放来实现角色个性皮肤上的局部特效表现。 利用缩放来表现角色的身高差异或适应不同界面的展示需要。

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



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3