在游戏开发界,PVP对战和相关系统的构建往往面临诸多挑战,同时也孕育着创新。这其中蕴含着许多值得深入探讨的有趣话题。
帧同步方案的确立
玩传统IO类小游戏的PVP模式会遇到不少难题,比如人数过多导致游戏难以进行,这使得PVP模式不太划算。但如果我们用ECS结构来开发单挑篮球游戏,就能天然实现帧同步,因此我们选择了这个方案。在开发过程中,这算是一个比较明智的决定。某论坛上的一位大佬也专门写文章介绍了这一点。不过,这个方案也有不足之处,通用性强的框架通常需要牺牲性能,比如协议未压缩等问题,导致帧消息体积较大,这对需要高频操作的篮球游戏来说并不友好。
在游戏开发过程中,各种游戏玩法和操作频率对画面同步效果有很大影响。以篮球游戏为例,在频繁操作中,较大的画面信息会导致诸多问题。此外,在应用画面同步技术时,往往难以有效管理服务器的负载。一旦出现问题,仅靠提交工单处理,耗时较长,效率不高。
自建联机对战平台
开发APP时,我采用了Go语言,参照MGOBE的模式,独立开发了一套在线对战平台。该平台的API命名与MGOBE保持一致,因此客户端无需做太大调整即可实现对接。平台具备tcp和udp两种通信协议的支持,这是它的一个显著优势。不过,udp协议存在指令冗余,网络状况不佳时,消息包容易堆积,这对低端手机来说不太友好。因此,为了低端手机的连接体验,我们选择了tcp协议来连接对战服务器。这样的设计可以确保不同设备在游戏中的连接稳定性。
选择网络连接方式时,要全面考虑设备状况。对于配置较低的智能手机,其网络连接的稳定性可能不足。采用TCP协议,可以在与对战服务器连接时,有效减少许多潜在问题。
帧同步问题排查
客户端负责帧同步的计算,这就意味着两个客户端的计算结果必须完全相同,否则画面会出现不同步。在初期上线的单挑篮球版本中,出现了不少画面不同步的问题。因为服务器无法实时判断客户端是否同步,所以问题的排查只能通过分析客户端日志来完成,这无疑加大了排查的难度。
游戏画面不同步会影响玩家的游戏感受。同时,解决问题的方式不多,主要依靠客户端日志来寻找线索。这对开发者而言,是一项不小的挑战,需要投入大量时间去仔细分析日志内容。
游戏重启与追帧
游戏在重启追帧和途中追帧的步骤上大体一致,只是重启追帧需要重新进入战场,并且需要从第一帧开始追踪。对于像单挑篮球这样的游戏,单局只需1到2分钟,从头开始追帧并不会带来太大压力。采取合理的追帧策略,对确保游戏顺畅运行至关重要。
游戏持续时间不同,对追帧技巧的适用性各异。比如,篮球单挑一局时间不长,实时追帧尚可忍受,可若游戏持续时间较长,可能就需要更高效的追帧方法,以减少对玩家体验的负面影响。
AI行为树的设计
以小游戏中11分玩法为例,我针对10个不同难度,设计了10个行为树。这些行为树间的区别细微,主要在于AI对行为的响应速度和触发几率。此外,我还对AI每个行为的具体触发点进行了限制,并通过配置表来调节行为出现的几率和速度。这种精细的安排,有助于增强AI的智能程度。
游戏难度各异,对AI的策略也有所不同。精心构建AI的行为树,可以使游戏中的AI行为更贴合游戏难度,从而提升玩家的游戏感受。
Spine换装系统改进
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)
}
将50个角色划分为50个脊骨,虽然这算是个方法,但每个脊骨都包含动画,这样一来就需要大量动画,每增加一个动画,就得在所有脊骨上同步更新,这对美术团队来说是个不小的压力。此外,这里的API与JavaScript不一致,有些方法和属性未导出时还会出现错误,因此我修改了脊骨在C++运行库中的代码。
游戏开发过程中,各系统模块之间相互牵连。Spine换装系统的升级需与动画等内部功能相协调,如何在不多增美术等人员负担的前提下优化系统,实为一大挑战。
在游戏制作过程中,大家是否也遭遇过类似的难题?期待大家能点个赞、转发这篇文章,并踊跃留言交流。
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)
}
}