背景
玩家视野设计背景
AOI(Area of Interest)
,即感兴趣区域。可以看成在游戏中玩家在所在场景中实时看到的区域,即AOI会随着玩家的移动而改变。在游戏中,会有很多个场景(地图),每个地图中都有很多玩家或者npc,视野数据是相互的, 玩家A 可以看到 玩家B , 玩家B 同时也会看到 玩家A 。当 玩家 在地图中做一些行为操作的时候,如果想要玩家的行为被别人看到,就需要把玩家的行为广播给地图中的所有玩家。场景中玩家的行为只要有修改就广播给场景中其他所有玩家,这种方式是最优的方式呢?
- 对于一些玩家数量较少的场景,比如组队副本等。玩家会看到地图中所有的玩家和npc,可以称之为
全视野场景
。这种场景中,单个玩家有操作采取广播给场景中的其他玩家的方式是可行的。 - 对于一些
大型的PVP玩法
,同一个场景中有几百甚至上千人参加的时候,假设场景中共有1000人参加。任意一个玩家只要移动就要广播给剩下的999人,当1000个人同时移动的话,服务器需要处理999 * 999(百万级别)条消息,相当于 n*n 。场景中所有人移动一下就产生百万级别的数据
,还没有考虑玩家的技能释放等其他的行为操作的同步,这么大数据量容易造成服务器消息的堆积,无法做到及时响应。同时,客户端每一个玩家都会处理其他1000个玩家的实时信息,容易造成客户端的卡顿,如果消息处理不过来也会出现包数据的丢失。同时发往客户端的包的数量太大,还会消耗大量的流量。这种方式肯定不是我们想要的,游戏体验会非常差。
我们可以发现,在游戏中,玩家始终会出现在屏幕的中央,随着玩家的移动,视野也会随着玩家的移动同时移动,但是始终都是以玩家为中心。玩家所能看到的就是游戏屏幕内以玩家为中心向四周360度扩展的视野信息。那么如果修改玩家的 AOI ,控制在以玩家为中心 屏幕内所有所见对象记为玩家的视野数据
;当玩家有行为操作的时候,只需要通知在玩家视野中的其他用户,相对于通知场景中的全部玩家来说可以减少大量没有必要的消息的推送,同时也能节省很多流量。因为距离太远的其他用户是不在玩家当前屏幕显示范围内的,把玩家的行为操作通知给这些玩家是没有什么意义的。
但是如果该场景中所有玩家都在打一个boss,场景中玩家全部都集中在一起,那么玩家当前屏幕中的视野数据又变成场景中的全部玩家了。同时受客户端性能的限制,客户端有最大的同屏人数上限,因此我们需要限制玩家的视野上限
。当场景中玩家全部集中的一起的时候按照优先级(社交关系,敌对关系,距离等)进行筛选,筛选出指定数量的对象算入玩家的视野对象。
因此,针对大型PVP玩法的活动,当场景中人数非常多的时候,玩家的视野采用限制视野上限,并且以玩家为中心360度指定范围设定为玩家视野
的方式。以玩家为中心,向四周360度扩展,可以延伸出以玩家为中心的正方形区域,也可以是以玩家为中心的圆形区域。但是由于地图时正方形的,圆形并不适合进行座标范围临界点的计算,因此最好的就是按照以玩家为中心向外360度扩展的正方形区域。我们可以发现围出来的刚好是一个以玩家为中心的正方形区域。因此可以想到按照各自划分地图区域,玩家在格子的中心,以玩家为中心的正方形区域刚好可以分成九宫格 3 * 3。并且九宫格的区域需要大于等于游戏屏幕的显示区域
,根据具体情况合理设计每个地图格子的长度即可。因此玩家的视野数据可以采用九宫格存储
的形式。
地图
地图划分格子
在游戏中,地图大小都是正方形,并且是以 X 轴, Z 轴为水平面的(游戏中都以cm为单位)。把地图按照指定的大小分成若干个格子,如以 7m(需要根据实际的地图大小设置单个格子的长度大小) 为单位划分块。当地图被划分为多个地图块之后,通过玩家的座标点可以计算出玩家哪个地图块中。
在 X 轴方向进行划分,X轴地图总长度为256m,假设每个块的大小为7m,那么在X 轴,共有多少个格子呢? 256 / 7 = 36.57142857142857,不满足一个格子的直接按照 1 个格子来计算,即X 轴上共有36 + 1 = 37块格子,Z轴同理。假设每个块的大小为8m,256 / 8 = 32,刚好可以划分为32个块。当地图大小固定的时候,不同大小的块,会得到不用的格子数,有的可以整除,有的不能整除(不能整除的不满足 1 块大小算作 1 块),即向上取整
。为了避免进行是否能够整除的条件判断,统一采用加 1,即采用 地图长度 / 每个格子长度 + 1 来计算划分的格子数。因此我们划分的总格子数总是会比场景中的地图要大的
。假设地图大小是256m * 256m,每个格子的大小是7m * 7m,下面都是按照这个大小进行讨论。
X 轴的总格子数 X_AREA_NUM_PER_MAP = X轴地图长度 / X轴每个格子长度 + 1
Z 轴的总格子数 Z_AREA_NUM_PER_MAP = Z轴地图长度 / Z轴每个格子长度 + 1
整个地图共有的格子数 MAX_DYN_AREA_NUM = X_AREA_NUM_PER_MAP * Z_AREA_NUM_PER_MAP + 1(此处加 1 个格子是为了安全起见)
游戏中可以采用一维数组
的方式进行存储地图中所有的格子
。格子数 - 1 = 数组下标,通过下标进而得到数组中存储的对应共享内存ID,从而可以得到对应的视野块对象(1个格子可以视作一个视野块对象)。
地图的视野块(格子)和数组下标的关系如下:
后面可能会用到的变量:
X_AREA_NUM_PER_MAP
----------- X 轴方向划分的总格子数
Z_AREA_NUM_PER_MAP
----------- Z 轴方向划分的总格子数
MAX_DYN_AREA_NUM
----------- 整个地图所有的格子数
Actor对象
------------ 玩家 或者 NPC 等统称Actor对象
地图格子对象
------------ 视野块对象
座标点定位玩家所在地图块
如何根据具体的座标点找到玩家所处于哪个格子中呢?
X轴列方向:第一个格子的范围 [0,7),第二个格子范围 [7,14),第三个格子范围[14,21)…
Z轴行方向:第一个格子的范围 [0,7),第二个格子范围 [7,14),第三个格子范围[14,21)…
已知玩家的座标pos(8, 100, 15),假设地图大小为256m * 256m,格子的大小为7m * 7m。
可以先按照二维数组下标分析:
在 X 轴,X 所在格子数为 X = 8 / 7 = 1,即在X轴上第2列格子中(如图),X轴数组下标为1
在 Z 轴,Z 所在格子数为 Z = 15 / 7 = 2,即在Z轴上第3层格子中(如图),Z轴数组下标为2
按照二维数组计算下标的方式,玩家所在数组的下标为: Z * X_AREA_NUM_PER_MAP + X = 2 * 37 + 1 = 75
视野块
地图上每个格子可以看做一个视野块
,通过这个格子可以获取座标位于该格子里面的所有 Actor 对象。那么每个视野块中的所有 Actor 对象是如何存储起来的?
- 可以使用HashSet进行存储,存储在当前视野块中的所有玩家的ActorID。HashSet中元素不会重复,同时可以满足快速查找删除,可以满足我们的需求。
- 如果以单向链表的形式存储在视野块中的话,查找删除的复杂度都是O(n),显然不合适。可以发现视野块中存储该视野块中所有 Actor 对象的数据结构
需要支持快速查找、删除、插入
操作。可以想到,通过采用双向链表的形式实现。
但是双向链表也会弊端,它是通过获取Actor对象上存储的双向链表节点来知道下一个节点的对象的,如果某个对象不存在了,那么就获取不了下一个节点的值,视野块的双向链表就无法遍历了,只能重置。
双向链表存储视野块中所有Actor对象的具体实现如下:
在视野块对象中只需要存储链表的头结点 + 链表的总数
,头结点即Actor对象的ActorID(可唯一标识Actor 对象),然后在Actor对象中存储双向链表节点(链表上一个Actor对象的ActorID + 链表下一个对选哪个的ActorID)。这样通过每个视野块中的头结点即可遍历属于该视野块中的所有Actor对象。
双向链表删除
操作。这样的话当Actor对象移动到下一个视野块的时候,可以很快的进行链表删除
操作,BeforeA <----->ActorA<----->AfterA,若要从视野块中删除ActorA,直接BeforeA<------>After A,即可实现。
双向链表插入
操作。当有新的玩家移动到当前视野块,可以采用尾插法的形式实现链表插入
。通过视野块对象即可获取到头结点的HeadActorID,头结点的Actor对象中存储了双向链表节点,可以获取到上一个节点的ActorID,即尾节点的TailActorID。即TailActorID<------>HeadActorID,假设要插入的的为ActorA,,则TailActorID<------>ActorA<------>HeadActorID即可实现尾部插入。
假设双向链表数据结构如下:
struct STGIDListNode
{
// 同一链表前一个gid
ActorID m_iPrevGID;
// 同一链表后一个gid
ActorID m_iNextGID;
};
双向链表插入节点
stPushNode
:要插入的Actor对象的双向链表节点对象
iPushGID:要插入的Actor对象的ActorID
stTailNode
:尾结点
stHeadNode
:头结点
双向链表遍历
地图、视野块、玩家的关系
一个游戏会有很多个场景;
每个场景对应一个地图资源;
每个地图上会分为多个格子,每个格子都是一个视野块对象;
每个视野块上会有很多个玩家或者npc。
玩家视野
玩家九宫格视野
玩家视野变动会有以下情况:
主动
。玩家操作引起的视野刷新:
玩家进入地图,会搜索玩家的视野信息。
玩家移动,从一个视野块移动到另一个视野块(如果在同一个格子中移动,不会刷新视野)。被动
。其他原因造成玩家视野的变动:
其他玩家进入/离开视野,会引起自身视野的变动。
如何判定玩家是否进入进的格子?
通过新旧座标来计算,座标 / 每个视野格子大小
计算出所在格子数,通过判断格子数是否相同来判断是否在同一个视野格子来判断玩家是否进入新的视野块。
当玩家在同一个格子中移动的时候不需要重新搜索玩家视野
。为什么呢?
因为我们的视野信息是规则是按照一定优先级规则随机筛选九宫格中的玩家。如果玩家在同一个格子中移动的时候也要刷新视野,因为视野信息是随机筛选的,所以会造成玩家在同一个格子中移动的时候,每次筛选的视野信息是会不一样的,即周围的玩家一直在变换,玩家突然出现突然消失,这样并不是我们想要的效果,并且这样对体验是非常不好的,并且如果只要移动一下就重新搜索视野,视野搜索的调用也太频繁。因此,在同一个格子中移动的时候不需要重新搜索玩家的视野信息。
玩家的视野信息会直接存储在玩家身上
,当玩家有其他操作的行为的时候比如释放技能等,直接获取玩家的视野数据,广播给视野内的其他玩家即可。
玩家移动引起视野变动,玩家视野处理规则:
玩家每次移动到一个新格子,以新位置向外扩展的九宫格为标准,新增的格子即为重新搜索的格子(加入到玩家视野
) + 重叠部分的格子保留在视野中(玩家视野中继续保留
) + 不在新的九宫格中的是需要删除的格子(从玩家视野中删除
)。
视野信息一般都是相互的
,当把A对象从自己的视野中删除的时候,同时也要把自己从A对象的视野中删除。
玩家移动引起视野刷新的可能情况
玩家移动,新增搜索视野块的可能情况有以下几种:
玩家Before
:指的是玩家之前的位置
绿色:
新增的需要搜索的格子
黄色:
继续留在玩家视野中的格子
灰色:
需要从玩家视野中删除的格子
移动方向 | 视野改变相关的格子。 |
---|---|
向上 移动一格 |
|
向下 移动一格 |
|
向左 移动一格 |
|
向右 移动一格 |
|
向左上 移动一格 |
|
向右上 移动一格 |
|
向左下 移动一格 |
|
向右下 移动一格 |
|
其他情况 (移动超过一个格子) 因为新增视野块数量太大,因此直接需要重新搜索视野 |
举个例子,从A视野块进入到B视野块
,视野信息会如何进行处理呢?
玩家从A移动到B,A视野块对象的双向链表中删去玩家,B视野块对象中的双向链表插入玩家
优先级筛选视野规则
我们知道玩家的视野是有上限的,当九宫格视野块中的对象大于玩家的视野上限的时候,我们就需要有一些筛选规则,根据指定的优先级规则
选择选取相应数量的对象。
优先级枚举
:
0
:社交关系。
1
:交互npc
2-9
:8个不同等级的距离
社交关系、npc关系,一般用于可以直接通过玩家可以获取对应对象
这一类。比如队伍信息,镖车信息,这些可以直接通过玩家身上存储的信息获得对应的对象。
玩家视野筛选流程:
- 获取玩家在移动后的新位置需要
新增搜索的格子列表
。 - 获取玩家身上存储的视野信息,把
玩家当前视野中的所有玩家
都标记为 CURR_VIEW(玩家当前视野中)。 收集社交关系的视野
,比如玩家的队伍信息,遍历玩家当前队伍中的所有的成员(在线并且在同一张地图):
如果之前就在玩家的视野中(CURR_VIEW标记,2中已标记),修改视野标记为KEEP_IN_VIEW(继续留在玩家视野中)
如果之前不再玩家的视野中,加入到社交优先级队列中,等待后续筛选。收集npc视野
,比如镖车等,可以通过玩家身上存储的信息获取npc对象的这一类。
如果之前就在玩家的视野中(CURR_VIEW标记,2中已标记),修改视野标记为KEEP_IN_VIEW(继续留在玩家视野中)
如果之前不再玩家的视野中,加入到npc优先级队列中,等待后续筛选。处理距离相关视野
,遍历新增搜索的格子(1 中得到的)。针对每一个格子,遍历该格子(视野块中双向链表)中的双向链表,获取每一个链表对象:
若标记为KEEP_IN_VIEW的对象总数 < 玩家视野上限
并且链表对象之前就在玩家的视野中
(标记CURR_VIEW),修改视野标记为KEEP_IN_VIEW(继续留在玩家视野中);
否则
如果链表对象是npc,放入npc的优先队列中;如果是玩家,根据距离等级放入对应距离的优先级队列。新旧视野合并
。- 找出并把所有
新加入视野中的对象存储到EnterList
-------即筛选优先级列表中的玩家放入进入玩家视野列表计算剩余未分配名额LeftAllotNum
= 玩家视野上限 - 标记KEEP_IN_VIEW的玩家总数(前面肯定在标记为KEEP_IN_VIEW的时候设置了计数)。- 如果
剩余未分配名额 LeftAllotNum < 社交关系队列总数 + npc队列总数
,即可分配给社交关系和npc关系的名额不够,则随机删除保留在玩家视野中的玩家(标记为KEEP_IN_VIEW的,肯定不能删除和玩家是社交关系/npc),删除后即为社交关系+npc优先级队列留余额。 - 给最高优先级的队列(社交关系+ npc)分配进入视野的名额。(最高优先级队列的对象全部进入视野,选取粒度设置为1)
- 给各距离优先级队列分配进入视野的名额。根据权重对剩余名额LeftAllotNum进行分配,分配到不同距离优先级队列中,同时分配选取粒度(选取粒度 = (队列中元素总数 - 1) / 可给该队列分配的名额数 + 1)。如果各个距离优先级分配完之后还有剩余名额,直接分配给第一距离优先级队列。
- 各个优先级队列根据已经分配的名额随机选取进入视野的对象EnterList列表,筛选出来的对象存入。首先
按照指定的粒度
进行选取(达到随机的效果),如果还有剩下的名额没用完,按照1粒度
选取,如果还有剩下的,直接分配给下一个优先级队列
。
- 找出
离开视野的对象存储到LeaveList,并从视野中删除
-------即删除需要离开玩家之前视野中的对象- 遍历玩家身上存储的当前视野列表数据,标记为CURR_VIEW的即是需要删除的对象(因为如果要继续保留在玩家视野中,那部分对象已经标记为KEEP_IN_VIEW了)。把要删除的对象存储到LeaveList,并且从视野中删除,同时从对方的视野中把自己删除(因为视野是相互的)。
把EnterList列表中的对象加入到玩家视野
------------------------把优先级列表中筛选出来的玩家加入玩家视野- 遍历EnterList中要加入到玩家视野的对象叫做A。如果A是玩家,并且A的视野已满,则找一个距离足够远的,从A的视野中删除,并且也把A从对方视野中删除。这样A的视野就有一个位置来加入当前玩家了。A和玩家互相加入视野。
- 下发视野变化数据,离开视野的notify + 进入视野的notify
向2中LeaveList列表中的玩家下发离开视野的通知
向1中EnterList列表中的玩家下发进入视野的通知
- 找出并把所有