【游戏客户端】大话slg玩法架构(全)
大家好,我是Lampard家杰~~ 今天我们继续给大家分享SLG玩法的实现架构,关于SLG玩法的介绍可以参考这篇上一篇文章:【游戏客户端】制作率土之滨Like玩法
PS:和之前一样,本文也只是分享实现思路,并不会贴具体的代码和资源哟
(一)架构总览
SLG玩法的实现思路可以划分为四个部分,分别是滚动容器基类的搭建,背景大地图的实现,建筑的生成与刷新,以及玩法优化预加载相关
OK,那么我们一步步开始与大家细细分享~~~
(二)滚动基类的实现
(1)scrollview滚动容器
首先和大家说明一下,这款SLG是一个纯界面玩法,策划大佬希望它可以像背包一样随时打开关闭,且不影响原先的游戏流程
因此在预演的时候我们有两套思路,第一是利用本身就自带滚动逻辑的scrollview实现滑动功能,然后把点击事件的监听放置在建筑上;第二是自己实现一个触摸层,滚动和点击的逻辑写在这个触摸层上,建筑仅作为显示用途
后面由于考虑到scrollview支持多方向滑动,且玩法本身的点击事件都是发生在与建筑交互的过程,因此我们采取了第一种做法
(2)尺寸与大小
关于这个容器有两个尺寸大小需要我们设置的,其一是显示的尺寸(也就是应用实机的分辨率),由于玩法需要铺满整个屏幕,因此我们需要对SV的尺寸通过setSize的接口来设置成实际的size
然后第二个就是InnerContainer的尺寸,此时肯定有部分同学不知道这InnerContainer是啥东西,遇事不决问GPT同学
Q : 什么是InnerContainer ?
A:在 Cocos2d-x 游戏引擎中,InnerContainer 是 ScrollView 控件的一个内部容器,用于容纳 ScrollView 中的子节点。ScrollView 是一个可滚动的视图容器,InnerContainer 则是这个滚动视图的内部容器,负责承载实际的滚动内容,并响应用户的滚动操作。
简单来说这个SV就好像一个望远镜,我们透过镜头可以看到前面有两棵树,而InnerContainer则是整片树林,我们可以通过调整镜头的位置来观看树林的全貌
那么这片树林究竟有多大呢?这取决于策划和美术大佬的设计啦,假设这是一个十行十列的大地图,一个地图的大小为1000像素,那么这个InnerContainer的大小就是10000 * 10000了
我们可以通过setInnerContainerSize的接口设置其大小
(3)跳转与滚动
那此时们需要实现跳转和滚动功能就很简单了,比如说我们想跳转到第二列第一行的地图块位置(2,1),按上述的地图尺寸假设值,那么只需要设置innerContainer的位置为(- 2 * 1000, - 1 * 1000)即可
为什么是负数呢?是因为SV实际是不动的,而0.0是innerContainer的左下角位置,因此我们SV“镜框”不动的情况下,想看右上方的内容,那只能让"树林“往坐下角移动了
因此我们就可以很简单的包装一个jumpTo接口实现跳转功能
-- 界面跳转至某一地图块
function jumpTo(Idx, Idy)
......
local PanelPosX = -Idx * 地块长度
local PanelPosY = -Idy * 地块高度
self.SV:getInnerContainer():setPosition(PanelPosX, PanelPosY)
......
end
而滚动就更简单了~~比如说需要从(1.1)滚动至(1. 2),我们掐指一算发现只需要把 innerContainer往左移动1000像素,那么我们就可以启用一个计时器,让innerContainer每帧移动1000 / 移动需要的帧数即可
(三)背景地图的实现
(1)大地图资源的输出方式
通过上一篇文章我们知道,我们需要一张大图来铺满滚动容器的InnerContainer来实现背景地图的效果。那这个时候怎么输出这个资源成了一个需要考量的地方
直接输出一个10000 * 10000的png吗?那加载的时候不得卡鼠。所以必须要采取分块的方式,比如你可以把一块地图的大小设置成1000 * 1000,那么我们就把一张大地图分成了100张小地图
第二个问题就是,那这个原始的10000*10000分块前的资源要怎么输出,如果单靠美术同学去画一张完整的图工作量是很大的,这个时候我们就可以采取代码重复拼接的方式,比如美术只输出2000 * 2000 (4块资源)组成的地图,我们在代码里每隔2000像素就重复利用一次资源
function getBgRes(Idx, Idy)
local ResTbl = {
[1] = {[1] = 资源1, [2] = 资源2},
[2] = {[1] = 资源3, [2] = 资源4},
}
local ResX = Idx % 2 + 1
local ResY = Idy % 2 + 1
return ResTbl[ResX][ResY]
end
只要处理好资源拼接时候的缝隙,那么我们一方面可以降低大地图的实现成本,另一方面可以减少资源的读取,降低资源缓存
(2)地图块的控件复用
有了地图的生成方式,后面只需要像摆积木一样一块块摆在滚动容器上就可以了。那么不知道面对这100个地图块,大家会选择怎么创建呢?
如果直接一个for循环,那么一进游戏就卡成ppt了。有经验的同学可能会采取异步加载的方式分帧去创建,这样确实可以,但是100个地图块生成完之后,光这些地图就已经占据了几百M内存(苹果2G内存瑟瑟发抖)
而且,哪怕是采取了异步创建的方法,在创建的流程中也是感受到明显卡顿的。其实玩家能看到的无非就是屏幕上那几块地图,哪怕玩家快速的上下左右滑动,我们也只需要多生成一屏的地图就可以了,这样不过十来块的数量。因此我们不需要把100个地图全生成,只需要生成十来个实际用到的,然后监听玩家的滑动,更新当前所视范围地图的纹理并重设它们的位置就可以了
-- 释放
for x = 1, 10 do
for y = 1, 10 do
local IsInView = 判断一下这一块地图需不需要显示
if not IsInView and self.Map[x][y] then
local Map = self.Map[x][y]
Map:setFree()
self.Map[x][y] = nil
end
end
end
-- 生成
for x = 1, 10 do
for y = 1, 10 do
local IsInView = 判断一下这一块地图需不需要显示
if IsInView then
if self.Map[x][y] then
-- 存在这个地块就设置可视就行
local Map = self.Map[x][y]
Map:setVisible(true)
else
-- 不存在则找一个空闲的地块控件重设信息
local Map = getFreeMap()
self.Map[x][y] = Map
Map:resetInfo(x, y)
end
end
end
end
(3)更新地图块的时机
那什么时候执行这段地图更新代码呢?一开始我是直接每一帧都做一个判断,后来发现这样很多余而且比较耗CPU,其实我们需要监听玩家的滑动操作,滑动距离超过一个地图块的长/高的时候再执行
self.SV.OnScrolling = function()
local CurPos = 获取当前位置
if not self.SV.LastPosX or not self.SV.LastPosY then
self.SV.LastPosX = CurPos.Idx
self.SV.LastPosY = CurPos.Idy
end
if 超过滑动距离 then
self.SV.LastPosX = CurPos.Idx
self.SV.LastPosY = CurPos.Idy
执行更新地图的方法
end
end
(四)建筑控件的实现
(1)控件的结构
建筑是玩法中的核心逻辑,玩法就呈现在每一个建筑的UI上。因此整理好一个建筑控件的展示结构与层级关系是很有必要的
比如帮派纷争设计的建筑层级就是:建筑状态底板(我方=蓝色,敌方=红色,中立=白色) < 建筑纹理 < 建筑UI(名字,坐标,头像等) < 交互按钮(如分享,战报) < 特效表现 (战斗中,免战状态等)
BUILD_PRIORITY = {
BG = 1,
TEXTURE = 2,
UI_NODE = 3,
BUTTON = 4
EFFECT = 5,
}
其中建筑UI内容也有需要,它们也需要细分一个显示层级,因此可以先往建筑上挂一个UINode,然后把建筑UI挂在这个节点上再细分即可
(2)控件的生成逻辑
和大地图一样,首先第一步要知道这个控件生成出来之后要摆放到哪里。帮派纷争是一个77 * 77的小型slg玩法,那么我们将会有77 * 77个建筑位置。
每一个位置的建筑我们都可以通过接口计算出它在滚动容器上的坐标
function getPosById(Idx, Idy)
-- BG_WIDTH = 大地图宽度
-- BG_HEIGHT = 大地图高度
-- BG_NUM = 每一行/列的地图块数量
local PosX = (BG_WIDTH / 2) * (Idx + Idy - 1)
local PosY = (Idy - Idx + BG_NUM) * (BG_HEIGHT / 2)
return PosX, PosY
end
我们可以用一个Table去存生成的建筑对象,如果是1*1的数据(如下图)我们就可以直接赋值BuildMap[2][2] = 生成的建筑对象
如果是X * X的建筑,我们会把建筑对象记录左下角的坐标,如下图BuildMap[10][13] = 生成的建筑对象。那其余的位置怎么办呢?很简单,我们可以标识一个特殊的建筑类型--阻碍建筑,把其余八个位置标记为阻碍,玩家就不能迁城进攻到这些地方来了
(3)控件的数据获取
这是一个多人的实时同屏的玩法,因此建筑的所有数据(等级,占领状态等)都依赖服务端进行下发
把所有的数据一条协议下来那肯定是不科学的,我们用不上那么多数据,而且数据量太多协议肯定是顶不住。因此我们可以采取AOI的方式,根据目前所视的地图位置请求一定范围的建筑数据
比如目前所示的视野中心是(8,14),我们只需要请求以(8,14)为原点,半径为5的建筑数据(当然可以预留一些避免滑动时穿帮)。并且我们可以在滚动的时候设置监听事件,如果滑动距离超过一定阈值,那么我们就再次请求数据
PS:视野中心需要根据滚动容器InnerContainer当前所在位置进行换算,半径也需要根据不同机型的分辨率进行计算
此时有一个好奇的朋友就会问了,如果我生成建筑的时候,建筑数据还没回来怎么办呢?
这个很简单哈,只需要把返回的服务端数据缓存起来!!在数据回来的时候判断一下该位置的建筑生成了没有,如果没生成那么不用管它,它自然会从缓存中读取正确的数据。如果发现已经生成了,那也不要慌,同样先把数据缓存起来,然后执行一下那个建筑对象的update方法让其更新即可~~
(4)控件的刷新逻辑
和地图块一样我们不会生成所有的建筑。同样采取控件缓冲池的方式,在进游戏前先生成一堆建筑对象,然后根据目前所示的位置,更新建筑对象的信息以及位置摆放。从而使极限的建筑数量从77*77 转化为两三百个
-- 释放
for x = 1, 77 do
for y = 1, 77 do
local IsInView = 判断一下这一块建筑需不需要显示
if not IsInView and self.BuildMap[x][y] then
local Build = self.BuildMap[x][y]
Build:setFree()
self.BuildMap[x][y] = nil
end
end
end
-- 生成
for x = 1, 77 do
for y = 1, 77 do
local IsInView = 判断一下这一块建筑需不需要显示
if IsInView then
if self.BuildMap[x][y] then
-- 存在这个建筑就设置可视就行
local Build = self.BuildMap[x][y]
Build:setVisible(true)
else
-- 不存在则找一个空闲的建筑控件重设信息
local Build = getFreeBuild()
self.BuildMap[x][y] = Build
Build:resetInfo(x, y)
end
end
end
end
这里需要注意的是,getFreeBuild的时候,由于玩家可能会疯狂拖动导致建筑对象setFree不及时,那么就可能造成读取不到空闲的建筑对象,因此需要做一个保底处理,如果读不到的时候生成一个新的,然后塞进去缓冲池中即可
除了玩家的操作导致需要刷新建筑,还有可能其他人进行了操作,服务端需要同步最新的结果来更新显示。此时我们只需要和上文一样,在服务端下发新建筑数据时进行判定,有则更新,无则缓存即可
(五)加载逻辑的实现
(1)Loading界面
Loading界面这个相信大家都不会陌生,由于玩法规模较大,如果直接打开会让帧率掉成PPT,那么我们只需要加入一个loading界面,在里面完成音频资源,UI控件的缓冲池,纹理预加载等逻辑,从而自然的过度到玩法
由于过程中花费时间最长的,是UI控件缓冲池的生成,因此我们可以以缓冲控件总量为分母,已生成的缓冲控件为分子设置进度条的百分比。界面上也可以加上TIPS文本方便玩家代入玩法
(2)地图和建筑的缓冲池
上文我们提到,如果是滑动的时候再去生成建筑或者地图块这些控件,那么在滑动的一瞬间会有明显的卡顿手感,因此为了优化这一现象,我们需要提前生成所需要的控件,并缓存起来方便滑动的时候复用
一般的屏幕分辨率下估计需要用到30个地图块对象以及300个建筑对象,因此我把它们的生成逻辑都放在了loading上,然后问题来了,loading居然花了七八秒
QA和策划同学纷纷吐槽这个过程相当坐牢,为此后续再做了一个新的优化,由于避免玩家滑动 时候出现空白的为刷新状态,我们30个地图块对象和300个建筑对象是包含了预生成的内容(看到的是一屏,实际上生成了4屏的范围)
但是进入游戏的时候立刻去疯狂滑动的玩家估计是很少很少的,因此在loading的时候,这边只loading一屏所显示的控件数量,从而使加载时间控制在2秒左右
进入游戏后再启用一个计时器,把剩余所需的控件对象生成出来塞入缓冲池中即可
(3)纹理资源的预加载
大多数的引擎在读取纹理资源的时候,都会先去纹理缓存中看看是否已经存在,不存在才根据纹理路径去磁盘IO
因此像SLG这种建筑纹理和地图纹理都比较多的玩法,我们可以提前把纹理资源预加载到纹理缓存区中,生成的时候就可以直接获取这个Texture对象了
local Res = 纹理路径
local Tex = cc.Director:getInstance():getTextureCache():addImage(Res)
if IsValid(Tex) then
Tex:retain()
table.insert(self.TextureCache, Tex)
end
注意,由于我们预加载的时候并没有实际调用这个Tex对象,因此有可能会被引擎的自动内存清理机制给回收掉,因此我们在addImage的同时要给这个都对象加一个引用计数,确保我们的预加载操作不会作白用工,在玩法释放的时候再手动减去这些对象的引用计数即可
好啦~~关于SLG玩法的实现分享就到这里
感谢阅读,记得点赞和关注!!!