服务器与架构思考
游戏中常见的算法
权重计算(前缀思想,比如说要设计抽奖啊,怪物概率随机啥的)
- 可顺序遍历的序列
lua中的ipair可以满足,将权重值维护到一个list中,从总权重中随机一个值,在遍历前缀v过程去比较,返回k,k就是奖励的idx。
local w = {}
local totalW = nil
for k,v in ipair(cfg) then
if not cfg then return end
table.insert(w,v.weight)
totalW = totalW + v.weight
end
local key = math.random(total)
local res = 0
for k,v ipair(w) then
res = v + res
if(res > key) return k
end
int pre[n+1];
pre[0] = 0;
for(int i = 1;i < n;++i){
pre[i] = pre[i-1] + nums[i];
}
int key = rand() % pre[n+1] + 1;
插入排序(找到待排元素的位置,比如说要去维护一个单调栈啊,单调队列这些情况)
void insertionSort(int arr,int n){
for(int i = 1;i < n;++i){
int key = arr[i];//待插入元素副本
int j = i -1;//已排序区的倒序遍历第一个元素索引
while(j>=0 && arr[j] > key){
arr[j+1] = arr[i];
j--;
}
arr[j+1] = key;////已排序区的倒序遍历第一个元素索引,索引加1不就是位置了嘛
}
}
二分
int binary(int arr,int target,int n){
int l = -1,r = n;//建议计开区间的二分写法
while(l + 1 < r){
int mid = l + (r - l ) / 2; //避免溢出 INT_MAX-1 + INT_MAX
while(l + 1 < r){
if(arr[mid] == target) return mid;//返回的是第一个大于target的下标值
mid = arr[mid] > target ? r-- : l++;
}
return -1;
}
}
//库函数:low_bound()
//返回小于等于target的下表(0 ~ binary(arr,target,n) - 1)
A*算法
- 维护一个曼哈顿距离和2个list(openlist和closelist)
维护一个从小到大排列的cost队列
cross服
- 为了实现跨服,游戏代码从底层架构到上层业务逻辑的代码修改成本尽量减少
- 业务逻辑里尽量少关心或者不用关心是否在本服或者跨服。减少开发者的跨服功能开发复杂度,提高开发的效率,缩短开发周期。
3.1 client直连还是server转发
a) 假设直连,那么,跨服玩法时client要维持两个连接,在跨服里,要模拟玩家登陆,绑定session的过程,游戏服和跨服两边要同一时候维护两份玩家数据,怎样做到数据的同步?跨服要暴露给玩家,须要有公网訪问IP和port。对client连接管理来说较复杂。
b) 假设通过大区server消息转发,那么,server之间做RPC通信,连接管理,消息需额外做一步跳转。性能是否能满足?跨不跨服,对于client来说透明,跨服隐藏在大区之后,更加安全,不需再浪费公网IP和port。
gate服
正常情况下网关服务器就是去做IO接入服务器,充当框架中的IO服务器,只是负责客户端校验,分配并接入场景服,达到负载均衡的目的。
数据就是做简单的客户端校验和用户登录的服,做一个负载均衡去将场景服实体尽可能均分到场景服的地图中,一般是不实现逻辑的,做一个分发小助手。
我们现在的场景服很奇怪:敏感词过滤,玩家改名,落州前的数据都干,随机名字也干,禁用玩家设备,也就是做逻辑了。那一定程度上会有影响的。
基于有锁的消息队列实现,用作IO服务器与逻辑服务器消息同步通信的一种方式。
然后实现消息批处理的虚函数接口和增加任务到有锁消息队列的接口
ChatTimeTick中利用一个线程去定时批处理 聊天过程中的的消息
通过读取配置去获取一些 恶意访问的参数,比如说最大容忍攻击次数,达到多少TCP连接上限就踢人等参数
TCP任务池,4096连接上限
启动前行为:启动日志初始化写
启动合法性检查:写一个success到文件中。
同步消息给Login服,采用md5 HTTP进行同步的,目前网关多少人,TCP连接的在线人数有多少
连接Record服
TCPClient
Sync 使用范围锁(一次性可管理多个锁),保证发的都是轮询过的感兴趣事件的数据
force_Sync 加锁 直到把加密指令缓冲区中的数据都发送出去,不管发送成功没。
一直从加密指令缓冲区中拿数据,够一个完整的包就不需要再向套接口接收数据,而是从解密缓冲区中进行数据解包
recv 失败 就会有 errno == EAGAIN || errno == EWOULDBLOCK ,只需要调用等待套接口可读函数就好,如果成功就是返回具体的字节数据,如果对方发送关闭,就会发一个EOF,
当TCP发送缓冲区有数据要发送,内核就会将对应的fd.event置为读就绪,然后等待套接口准备好写入数据的操作
c++做逻辑 or lua做逻辑?
正常情况下,我们的背包系统可能需要在c++中去做,涉及到背包功能的初始化(道具孔位支持拖动)
lua这边就是一个背包list,道具就是一个道具item,折叠属性就是没有限制
如果是c++这边,我们可能需要去设计每个slot背包孔位上,道具得存在一个最大折叠数,满了可能就得去做折叠处理。
lua去做逻辑和c++做逻辑的区别?
c++性能高,实时性强,c++做逻辑就得去在c++中编写业务逻辑代码,然后设计数据库数据,数据库数据的设计就更复杂了,也就存在sql优化方面的需求。第一范式:省州市县,数据字段细分;第二范式:表中只存相关性高的数据,比如用户表;第三范式:表中不存其他不必要的数据,比如商品表中增加购买者的姓名等杂项信息,而是只存储一个userid,通过外键去user表,拿自己想要的玩家数据。
一般我们会选择把一些需要热更方便得定时活动,运营活动,PVP玩法等放在lua中写,对于固定的流程都放c++不更改,或者说提升性能,去使用多线程去做业务逻辑,lua的话就是个单线程的虚拟栈,不支持多线程,它是采用的自旋锁进行lua虚拟栈维护的,使得CPU一直忙等着 自旋锁的释放,这也就造成了单线程下,所有的业务逻辑卡死在一条蚂蚱上。性能就依赖于lua栈的访问了。
lua开发效率高,数据交互方便,如果逻辑都用lua去写数据,那么除了开发效率高以外,对于sql的设计只存在 id name bin(二进制数据)
比如公会中,就一个id(可能是公会id,可能是uuid),然后一个名字,二进制文件就是一个string,lua中得表等东西全部压缩成一个string。
比如最近在做的二级跨服和二级公会,因为有些数据就是和二级跨服进行捆绑的,所以你移动一级跨服中的子系统功能模块的数据,是不是也应该移动将他们移动到二级跨服中
我也有注意到我们的排行榜数据是开服自动同步,然后使用定时器去定时推送更新,那这样对于二级跨服数据的迁移,又不支持手动发,那就得去动一级跨服自动排行榜数据的同步逻辑了。
简单的通用功能开发流程
- 前后端协议制定,配置表的导表格式代码编写,需求分析与确认
- 据接口层
看看需不需要对导表后的配置文件进行重新改造,比如说之前全是item,改造完之后就是item[#item][typeId] 注意改造后的表要设置为只读。剩下的都是自己根据配置表进行简单的改造,页签1的id乘与10或者2得到页签2的id,然后就是return table表嘛
将其设为只读以后 在进行深拷贝元表无法继承 只能拷贝其中的值
通过2个机制去实现:第一个是弱引用(lua 的gc机制影响,栈使用一个数组来实现,pop就只是索引值-1,当减到0索引时候就可以认为这个数组实现的栈可以被释放掉内存,lua里面提供了一个_mode的元表,支持弱引用)
然后就是去限制它的拷贝层数,如果它是表,我们就重新修改它的元表方法,要么报错,要么只能找它自己,
-
业务层
获取或者初始化db数据 建一个的子表,专门存放xxx模块的数据,跨服数据也可以初始化,
注册事件ID(不管是协议事件也好,角色事件也好,定时器事件也好),注册对应读写数据事件的回调函数
然后具体回调函数再采用模块化思想,大需求大功能切分为小功能需求
比如我大功能:节日活动抽奖,节日活动数据的获取协议,填写节日活动的数据,在获取db和初始化db后发送一下这个获取节日获得的数据,也就是一个触发时机的问题
抽奖函数的编写,权重概率随机函数的编写for 读取奖励配置权重一遍,先计算总权重,然后再从1,总权重随机出一个值,再从第一个权重值遍历一个权重就加上这个权重值,然后拿随机出来的奖励权重值取与当前遍历位置的之前的权重和比较,大于就继续遍历,小于就代表随机的是前一个位置的奖励,返回奖励rewardItem.id
抽奖函数中,判断db数据有无,无就return,有db数据才代表玩家玩过这个玩法,玩家能够玩这个玩法,抽奖道具限制(1次就一个,10次就10个,主要看的是前端协议请求的是抽1次还是1000次,后端就是只做抽一次的逻辑),然后再看需求是不是需要广播大奖,然后调用领将的接口,发到背包后又触发背包奖励添加的协议,如果满了是叠加(叠加一般是前端做的效果),还是说发邮件上。
然后最后填充抽奖的协议数据,行为日志注册,这样运营平台才能查到这个玩家的道具消耗日志,至于这份场景服的数据要不要同步到跨服,那再调用场景服发到跨服的接口,把协议数据发给跨发,或者说通知跨服,等等 -
比如说场景服把数据准备好,然后通过协议,通过跨服发送数据的接口,把战斗数据发给跨服进行处理,跨服取进行进行战斗计算,然后再将战报数据和战斗结果返回给场景服,场景服再通知前端Unity去做动画播放。
业务逻辑代码思考
组队系统的开发
组队机制:addTeam onExitTeam onDisable onCreateTeam onJoinTeam
如果说要提供给lua接口进行调用,则直接编写lua栈c++ script
游戏状态管理
游戏状态基类 搭配ActTimer(由Timer基类实现TimeTick子类) 可用来做定时活动,运营活动
基类设计:
状态基类的设计:
enum eActState{
ActState_None = 0, /// 未定义状态
eActState_Notify = 1, /// 通知状态
eActState_Match = 2, /// 匹配状态
eActState_Prepare = 3, /// 准备状态
eActState_Start = 4, /// 开始状态
eActState_End = 5, /// 结束状态
};
- 状态基类handle方法 virtual void handle(eActStateController& c)
- 状态基类状态获取方法 virtual const void geteState()const = 0;
状态控制器类的设计:
typedef std::tr1::shared_ptrActStatePtr;
eActStateController - 状态执行动作 inline void action()
- 状态切换 inline void changeState(ActStatePtr const& val)
- 获取状态 inline getState()
后台的行为日志管理器
里面写好对应玩法的后台日志行为的同步接口,然后在玩法中调用这样一个写好的后台日志行为同步接口,把数据传参过去。
然后玩家不在线的时候转发离线数据给c++,离线数据是镜像数据中同步过来的,然后c++存数据库
具体的玩法后台日志行为接口:
比如说副本结算数据收集,然后调用离线转存后台日志行为数据接口
比如说魔界探险日志 周结算的记录
比如说抽卡记录行为日志
运营活动基础项的开发
ActTimerItem
延迟定时器管理器
定时活动的开发(DTT)
DTTaskBase定时活动基类
DTT注册()
DTT取消()
重载运算符 >
DTT TimeTick实现(run纯虚函数)
std::vector<DTTBase*> DTTasks;
lockDTTQueue
DTTasks tasks; //DTT任务集合
DelayTimerManager
lockDDTQueue taskQueue;//任务队列
DDTasks waitQueue; //等待调度任务队列
typedef unsigned long long QWORD;
std::set
QWORD deleteQueue;//等待删除任务
1s定时器 去定时任务vecor中拿,然后由定时TimeTick去轮询
virtual void run() = 0;
void run() override{
Lock lock(mutex);
while(!taskQueue.empty() && taskQueue.top().status == _ADDEXCUTE){//加锁定时任务队列非空且定时任务状态为已加入到定时器中
schedule(taskQueue.pop());
task.status = _EXCEUTE; //定时任务状态变为执行中
}
Timer timer(delay);
Lock unlock(mutex);
}
TimeTick基类的开发
跨服分房活动开发
分房类型 : 定时活动类型 or 无关联
玩法类型: GVG PVP 具体活动id
战斗类型与战报数据传输设计
- 对于客户端实时做战斗计算表现的游戏,我们可以选择服务器不做战报与战斗计算,服务端只是将战斗的初始数据(编队数据,编队上附属的卡牌数据,装备数据,宠物数据等等)发给客户端,通过一个随机因子,使得双方都能够随机出同样的序列,然后再把战斗数据和战报数据丢给战斗服进行校验一遍,跑一下逻辑验证,然后再播报,而且这种也可以实时暂停战斗数据,毕竟是客户端去做计算。
- 对于slg,卡牌回合制战斗类型的,既可以服务端做,也可以客户端做,只是服务端做就不需要再校验一遍,只是场景服把数据发给战斗服(第三方)进行计算整理战报,发给场景服然后让客户端播报
回合制战斗技能设计
战斗是两个编队进行对战,最终一方卡牌全部阵亡或者达到回合上限按照一定规则(比如剩余血量百分比)来判定战斗胜负,进战斗后不需要玩家控制,自动进行战斗计算。
目前战斗计算是用专门的战斗服,战斗服计算产生战报后,把战报和战斗结果发回给场景服,场景服把战报和结算信息发给前端播放显示。
战报的格式为{idx = xxx, eventType = xxx, param = xxx}, 即战报序号,事件类型,事件参数。
编队
编队主要有以下信息:
(1)玩家或怪物信息,用于显示战斗的队伍头像等信息。
(2)卡牌信息:每张卡除了卡牌基本信息,最主要有技能列表和属性列表。
(3)秘宝信息: 秘宝拥有自己的属性和技能
(4)援助卡信息: 援助卡也拥有自己的属性和技能
技能
技能由一系列效果组组成,主要有以下属性:
(1)类型属性:标识是普攻还是怒攻等
(2)消耗属性: 表示发动技能消耗的怒气
(3)发动概率: 触发技能前先触发一下随机数概率判断,判断通过才执行技能
(4)触发时机: 在什么时机触发(比如回合开始,回合结束前,角色死亡前, 某buff消失后等,经常新的需求需要实现新的触发时机)
(5)效果组id:具体由哪些效果组组成
效果组
效果组主要有以下属性,决定作用对象是谁,效果释放多少次:
(1)作用对象: 比如敌方前排,敌方全体,敌方血量最高单位等。
(2)目标条件id,根据目标条件id,可以将作用对象分成,满足目标条件和不满足目标条件的两部分,对两部分可以有不一样的效果组成。
(3)触发概率:效果组同样要经过1个触发概率判断
(4)施放次数类型:比如固定释放1次,或者1 + 怒气/n 等。
效果
效果组由效果组成,效果是真正产生作用的实体。效果组,决定了效果作用了哪些对象,作用多少次。效果产生真正的作用,效果主要有以下属性:
(1) 效果类型:包括 伤害、治疗、加buff、怒气变化、怒气转移、燃血、状态移除等
(2) 效果参数: 比如伤害类型的效果,需要伤害公式id参数,决定产生多少伤害;加buff效果,需要buffId参数决定加什么buff
(3)触发概率:效果也可以定义触发概率,默认一定发动
(4)目标条件id: 判断目标是否满足条件,满足才执行效果
(5)目标条件结果:1或0,默认是0,代表效果组的目标条件通过才执行效果,1则相反。
状态
技能中,还有1个重要的状态buff类,用来标记卡牌有特定的状态,这些状态可以有特定逻辑,比如灼烧状态,是每回合开始时扣一定百分比的血量。
护盾状态,可以提供一定的护盾值,在受到伤害时可以减少护盾值抵扣伤害。
很多技能需求都是开发一些新的状态buff。
总结
上面解释了战斗技能的主要组成元素,技能开发,主要就是确定技能什么时候执行(通常需要开发新的触发时机),技能作用于哪些对象,
可能要开发新的作用对象和目标条件id,技能产生哪些效果,一般都是用已有效果,也可能开发新的效果类型,
很大概率会要开发新的状态buff的逻辑和一些伤害或者属性公式id。
ai机制之路径规划
A* 算法
open_node = {} close_node = {}
从open_node中去优先遍历当前节点到下一节点的真实距离 + 下一节点到终点的直线距离最短的节点为下一遍历节点,到达终点就回溯,这样就找到了最短路径
这个距离叫做曼哈顿距离最优的节点。
A_star.lua
-- 创建节点对象
local function create_node(buildId, parent, edge)
local city = LOC.getCityCfg(buildId)
if not city then return end
local node = {
buildId = buildId,
x = city.posX,
y = city.posY,
g = parent and (parent.g + edge) or 0,
h = 0,
f = 0,
parent = parent
}
return node
end
-- 曼哈顿距离计算
local function heuristic(node, goal)
return math.abs(node.x - goal.posX) + math.abs(node.y - goal.posY)
end
-- 寻找最小节点
local function find_min_f(tOpen)
local min_node
for _, node in pairs(tOpen) do
if not min_node or node.f < min_node.f then
min_node = node
end
end
return min_node
end
-- 类A星算法实现
function M.a_star(startId, endId)
local tClose = {} -- 集合tClose,已经寻路过的点
local tOpen = {} -- 集合tOpen,等待筛选的点
local endCity = LOC.getCityCfg(endId)
if not endCity then return end
local currentNode = create_node(startId)
currentNode.h = heuristic(currentNode, endCity)
currentNode.f = currentNode.g + currentNode.h
tOpen[startId] = currentNode
local findPath = false
while Next(tOpen) do
local currentNode = find_min_f(tOpen)
local currentId = currentNode.buildId
-- 到达终点时回溯路径
if currentId == endId then
local path = {}
while currentNode do
if currentNode.buildId == startId then -- 起点不放进path中
break
end
table.insert(path, 1, currentNode.buildId)
currentNode = currentNode.parent
end
return path
end
-- 移动当前节点到关闭列表
tOpen[currentId] = nil
tClose[currentId] = currentNode
-- 寻找相邻节点
local edges = LOC.getBuildEdges(currentId)
for _, nextNode in ipairs(edges or {}) do
local nextId = nextNode.nextId
if not tClose[nextId] and not tOpen[nextId] then -- 不在集合tClose和tOpen中
local nextNode = create_node(nextId, currentNode, nextNode.side)
nextNode.h = heuristic(currentNode, endCity)
nextNode.f = nextNode.g + nextNode.h
tOpen[nextId] = nextNode
end
end
end
return nil -- 无路径
end
AOI的应用
area of interest
将客户端地图像素格子进行划分为小的像素区域格子,以玩家所处的地图索引为中心,维护着自己为中心的像素格子,格子上有许多数组,链表,代表着格子上视野可见的道具,npc,障碍物等;
点击查看代码
``` -- 定义 AOI 系统类 AOIGrid = {} AOIGrid.__index = AOIGrid-- 初始化 AOI 系统
-- @param cellSize: 每个格子的边长(例如 100)
-- @param gridSize: 总区域大小(例如 900x900)
function AOIGrid:new(cellSize, gridSize)
local self = setmetatable({}, AOIGrid)
self.cellSize = cellSize
self.gridWidth = gridSize // cellSize
self.gridHeight = gridSize // cellSize
self.cells = {} -- 二维数组存储格子对象 [x][y] = list of objects
return self
end
-- 将全局坐标转换为格子索引
-- @param x, y: 全局坐标
-- @return x_idx, y_idx: 格子索引
function AOIGrid:coordinateToCell(x, y)
local x_idx = math.floor(x / self.cellSize)
local y_idx = math.floor(y / self.cellSize)
-- 处理越界(假设坐标始终合法)
x_idx = math.max(0, math.min(self.gridWidth - 1, x_idx))
y_idx = math.max(0, math.min(self.gridHeight - 1, y_idx))
return x_idx, y_idx
end
-- 添加对象到 AOI 系统
-- @param obj: 对象 {x, y}
function AOIGrid:addObject(obj)
local x_idx, y_idx = self:coordinateToCell(obj.x, obj.y)
if not self.cells[x_idx] then self.cells[x_idx] = {} end
if not self.cells[x_idx][y_idx] then self.cells[x_idx][y_idx] = {} end
table.insert(self.cells[x_idx][y_idx], obj)
end
-- 移除对象
function AOIGrid:removeObject(obj)
local x_idx, y_idx = self:coordinateToCell(obj.x, obj.y)
if self.cells[x_idx] and self.cells[x_idx][y_idx] then
for i, v in ipairs(self.cells[x_idx][y_idx]) do
if v == obj then
table.remove(self.cells[x_idx][y_idx], i)
break
end
end
end
end
-- 查询指定矩形区域内的所有对象
-- @param x1, y1: 区域左下角坐标
-- @param x2, y2: 区域右上角坐标
function AOIGrid:queryArea(x1, y1, x2, y2)
local objects = {}
-- 计算覆盖的格子范围
local start_x = math.floor(x1 / self.cellSize)
local end_x = math.floor(x2 / self.cellSize)
local start_y = math.floor(y1 / self.cellSize)
local end_y = math.floor(y2 / self.cellSize)
-- 遍历所有覆盖的格子
for x_idx = start_x, end_x do
for y_idx = start_y, end_y do
if self.cells[x_idx] and self.cells[x_idx][y_idx] then
table.extend(objects, self.cells[x_idx][y_idx])
end
end
end
return objects
end
-- 示例:更新对象位置
function updateObjectPosition(obj, newX, newY)
aoi:removeObject(obj)
obj.x = newX
obj.y = newY
aoi:addObject(obj)
end
-- 初始化 AOI 系统(假设地图大小 900x900,格子边长 100)
local aoi = AOIGrid:new(100, 900)
-- 创建测试对象
local obj1 = {x=50, y=50}
local obj2 = {x=150, y=150}
local obj3 = {x=250, y=250}
-- 添加对象到 AOI
aoi:addObject(obj1)
aoi:addObject(obj2)
aoi:addObject(obj3)
-- 查询区域 (0,0) 到 (300,300) 内的对象
local results = aoi:queryArea(0, 0, 300, 300)
print("Found objects:", #results) -- 输出 3
-- 移除一个对象并重新查询
aoi:removeObject(obj2)
results = aoi:queryArea(0, 0, 300, 300)
print("Found objects after removal:", #results) -- 输出 2
</details>
<details>
<summary>点击查看代码</summary>
--[[
十字链表 AOI 算法实现
核心思想:在 X 和 Y 轴维护两个有序链表,快速定位周围实体
--]]
local AOIManager = {}
AOIManager.__index = AOIManager
-- 实体节点结构
local EntityNode = {
id = 0, -- 实体ID
x = 0, -- X坐标
y = 0, -- Y坐标
x_prev = nil,-- X轴前驱节点
x_next = nil,-- X轴后继节点
y_prev = nil,-- Y轴前驱节点
y_next = nil -- Y轴后继节点
}
local axisPrev = {
["x"] = "x_prev",
["y"] = "y_prev"
}
local axisNext = {
["x"] = "x_next",
["y"] = "y_next"
}
-- 创建 AOI 管理器
function AOIManager.new()
local self = setmetatable({}, AOIManager)
self.x_head = nil -- X 轴链表头
self.y_head = nil -- Y 轴链表头
self.entities = {} -- 所有实体 {[id] = node}
return self
end
-- 插入实体到有序链表
local function insert_sorted(list_head, node, axis)
if not list_head then
-- 链表为空,直接作为头节点
return node
end
local current = list_head
local prev = nil
-- 按坐标值升序插入
while current do
if current[axis] > node[axis] then
break
end
prev = current
current = (axis == "x") and current.x_next or current.y_next
end
-- 插入节点
if prev then
-- 插入到 prev 和 current 之间
if axis == "x" then
prev.x_next = node
node.x_prev = prev
if current then
current.x_prev = node
node.x_next = current
end
else
prev.y_next = node
node.y_prev = prev
if current then
current.y_prev = node
node.y_next = current
end
end
else
-- 插入到链表头部
node[axisNext[axis]] = list_head
list_head[axisPrev[axis]] = node
list_head = node
end
return list_head
end
-- 添加实体
function AOIManager:add_entity(id, x, y)
if self.entities[id] then
return false -- 实体已存在
end
local node = {
id = id,
x = x,
y = y,
x_prev = nil,
x_next = nil,
y_prev = nil,
y_next = nil
}
-- 插入到 X/Y 链表
self.x_head = insert_sorted(self.x_head, node, "x")
self.y_head = insert_sorted(self.y_head, node, "y")
self.entities[id] = node
return true
end
-- 移除实体
function AOIManager:remove_entity(id)
local node = self.entities[id]
if not node then
return
end
-- 从 X 链表移除
if node.x_prev then
node.x_prev.x_next = node.x_next
else
self.x_head = node.x_next
end
if node.x_next then
node.x_next.x_prev = node.x_prev
end
-- 从 Y 链表移除
if node.y_prev then
node.y_prev.y_next = node.y_next
else
self.y_head = node.y_next
end
if node.y_next then
node.y_next.y_prev = node.y_prev
end
self.entities[id] = nil
end
-- 辅助函数:从链表中提取节点(不破坏链表结构)
local function extract_node(node, axis)
local key_prev = axisPrev[axis]
local key_next = axisNext[axis]
local prev_node = node[key_prev]
local next_node = node[key_next]
if prev_node then
prev_node[key_next] = next_node
end
if next_node then
next_node[key_prev] = prev_node
end
node[key_prev] = nil
node[key_next] = nil
return prev_node, next_node -- 返回原相邻节点用于快速重定位
end
-- 辅助函数:将node插入target节点后方
local function insert_after(target, node, axis)
local key_prev = axisPrev[axis]
local key_next = axisNext[axis]
node[key_prev] = target
node[key_next] = target[key_next]
if target[key_next] then
target[key_next][key_prev] = node
end
target[key_next] = node
end
-- 辅助函数:将node插入target节点前方
local function insert_before(target, node, axis)
local key_prev = axisPrev[axis]
local key_next = axisNext[axis]
node[key_next] = target
node[key_prev] = target[key_prev]
if target[key_prev] then
target[key_prev][key_next] = node
end
target[key_prev] = node
end
-- 移动方法
function AOIManager:move_entity(id, new_x, new_y)
local node = self.entities[id]
if not node or (node.x == new_x and node.y == new_y) then
return false
end
-- 记录旧坐标用于比较
local old_x, old_y = node.x, node.y
node.x, node.y = new_x, new_y
-- X 轴位置是否需要调整
if new_x ~= old_x then
local axis = "x"
-- 检查是否需要调整位置(利用原相邻节点快速定位)
local should_move = false
-- 情况1:新坐标小于前驱节点的x
if node.x_prev and new_x < node.x_prev.x then
should_move = true
-- 情况2:新坐标大于后继节点的x
elseif node.x_next and new_x > node.x_next.x then
should_move = true
-- 情况3:是头节点且新坐标大于后继节点
elseif not node.x_prev and self.x_head == node and node.x_next and new_x > node.x_next.x then
should_move = true
end
if should_move then
-- 从原位置提取节点
local prev_x, next_x = extract_node(node, axis)
-- 查找新插入位置(利用原相邻节点加速)
local current = nil
if new_x > old_x then
-- 向右移动,从原后继开始查找
current = next_x
while current and current.x < new_x do
current = current.x_next
end
else
-- 向左移动,从原前驱开始查找
current = prev_x
while current and current.x > new_x do
current = current.x_prev
end
end
-- 插入到合适位置
if not current then
-- 插入到链表头部
if self.x_head then
self.x_head.x_prev = node
node.x_next = self.x_head
end
self.x_head = node
else
insert_after(current, node, axis)
end
end
end
-- Y 轴位置是否需要调整
if new_y ~= old_y then
local axis = "y"
-- 检查是否需要调整位置(利用原相邻节点快速定位)
local should_move = false
-- 情况1:新坐标小于前驱节点的y
if node.y_prev and new_y < node.y_prev.y then
should_move = true
-- 情况2:新坐标大于后继节点的y
elseif node.y_next and new_y > node.y_next.y then
should_move = true
-- 情况3:是头节点且新坐标大于后继节点
elseif not node.y_prev and self.y_head == node and node.y_next and new_y > node.y_next.y then
should_move = true
end
if should_move then
-- 从原位置提取节点
local prev_y, next_y = extract_node(node, axis)
-- 查找新插入位置(利用原相邻节点加速)
local current = nil
if new_y > old_y then
-- 向右移动,从原后继开始查找
current = next_y
while current and current.y < new_y do
current = current.y_next
end
else
-- 向左移动,从原前驱开始查找
current = prev_y
while current and current.y > new_y do
current = current.y_prev
end
end
-- 插入到合适位置
if not current then
-- 插入到链表头部
if self.y_head then
self.y_head.y_prev = node
node.y_next = self.y_head
end
self.y_head = node
else
insert_after(current, node, axis)
end
end
end
return true
end
-- 批量移动
function AOIManager:batch_move(moves)
-- 1. 预计算所有移动的新位置
-- 2. 按坐标变化方向排序
-- 3. 批量调整链表顺序
end
-- 查询指定范围内的实体
function AOIManager:query_range(x, y, radius)
local results = {}
local min_x, max_x = x - radius, x + radius
local min_y, max_y = y - radius, y + radius
-- X 轴范围扫描
local current = self.x_head
while current do
if current.x > max_x then
break
end
if current.x >= min_x then
-- Y 轴二次过滤
if current.y >= min_y and current.y <= max_y then
table.insert(results, current.id)
end
end
current = current.x_next
end
-- 可选优化:同时扫描 Y 轴链表取交集
return results
end
-- 示例用法
local aoi = AOIManager.new()
-- 添加实体
aoi:add_entity(1, 10, 20)
aoi:add_entity(2, 15, 25)
aoi:add_entity(3, 5, 15)
-- 查询 (x=12, y=22) 半径5范围内的实体
local entities = aoi:query_range(12, 22, 5)
print("附近实体:", table.concat(entities, ", ")) -- 输出: 1, 2
</details>
客户端寻路算法使用和地图场景进行AOI切分,将像素区分;
服务器实现AOI算法并在对应区域进行刷怪;
https://blog.csdn.net/morphyyang/article/details/78307787
这里的帧指的是逻辑帧,做的也就是数据同步,服务端把场景服所有npc节点的数据整合成一个逻辑帧发给客户端。客户端端要做的就是状态同步表现同步,客户端总不能所有数据都同时渲染呈现给玩家,所以只能同步它当前节点所在位置感兴趣的区域的事件。
前端其实只知道自己是处于第几帧,不知道接下来是播放哪几帧的表现,所以就需要一些额外数据去告诉客户端当前属于第几帧,和后面要执行的是第几帧。
寻路算法一般是2种,要么前端发起的寻路算法,要么是后端发起的寻路算法。
slg中打一块地,前端获取一个坐标,发给服务端,服务器进行校验运算,实时计算运行的轨迹,A*算法嘛
使用最多的就是A*算法了,但是它是IO密集型的,计算太频繁了,CPU占有率高,所以一般只在boss使用A*,小怪可能就是扇形寻路了,避免大量物体使用A*寻路计算。
对于MMO游戏呢,寻路过程会遇到很多规则运动的boss和小怪,他们都是由 AI行为树 去控制的,对于npc的移动,则普遍使用A*算法去做会比较好,A*算法又分2种实现方式,各有优势,九宫格法对于离开的9宫格发送离开事件,对于进入的九宫格就发进入的事件
主要使用的就是2种数据结构去做A*寻路算法,分别是九宫格法和十字链表法,九宫格法有点类似于迪捷斯卡尔算法,碰到障碍物后就会回退到之前那个位置,对于所有遍历过的点都打上标记,已经访问过了,
平均帧运算优化A*(slg项目 使用八个线程去做寻路计算)
有时候,大量物体使用A*寻路时,CPU消耗比较大。
我们可以不必一帧运算一次寻路,而是在N帧内运算一次寻路。
(虽然有所缓慢,但是就几帧的东西,一般实际玩家的体验不会有大影响)
所以我们可以通过每帧只搜索一定深度 = 深度限制 / N(N取决于自己定义多少帧内完成一次寻路)。
MMO寻路的各种实现方式:
1、服务端执行寻路,客户端纯粹表现
2、客户端执行寻路,服务端验证路径正确性与结果(可能抽样验证)
3、客户端和服务端都执行寻路,并保证同步
至于是谁去做寻路功能,取决于NPC是否需要寻路,那么服务端就得具备寻路功能。
一般采用得方式都是,服务端具备寻路功能或者说是验证功能,建议不使用unity自带得nav mesh寻路功能,因为unity得寻路是基于碰撞检测实现得,碰到三角旮旯就卡死那了,如果一个怪物是扇形移动,而不是A*移动算法,或者是行为树移动算法。为什么不使用Dijkstra算法?,因为后者是从起点到图中所有节点得最短路径计算,不是起点到终点得最短。
如果非得使用Unity去做双端同步,那就使用或者自行开发中间件,实现。
有一说一,前端还是有许多同步优化算法的,可以去做的实现的。
## ai机制之行为结构
### 有限状态机
<details>
<summary>CStatus</summary>
class status :private hNoncopyable
{
public:
virtual void handleInput(Player& player,const Input& input) = 0;
}
class Stand final: public status{
void handleInput override(Player& player,const Input& input){
if(input == key_up){
jump_logic();
player.status = jump;
}else{
if(input == key_down){
sneak_logic();
player.status = sneak;
}
}
}
};
class Run final: public status{
void handleInput override(Player& player,const Input& input) {
......
}
};
int main(){
Player player = new Player();
Scene scene = new Scene();
player_init();
scene.init();
player.setState(new StandState());//初始人物状态
while(true) player.getState().handleInput(player,input);
resource();
return 0;
}
</details>
有限状态机的缺陷:
- 各个状态类之间互相依赖很严重,耦合度很高。
- 结构不灵活,可扩展性不高,难以脚本化/可视化。
### 行为树
<details>
<summary>BehaviorTree</summary>
class Node: private hNoncopyable{
public:
virtual bool excute = 0;
}
class NonLeafNode{//控制节点
public:
std::vector
void addChildren();
virtual bool excute() = 0;//执行函数,返还 成功/失败
}
class SelectorNode final: public NonleafNode{
virtual bool excute override(){
for(auto child:children){
if(child->excute == true) break;
else return false;
}
}
return true;
};
class SequenceNode final: public NonleafNode{
public:
virtual bool excute override(){
for(auto child:children){
if(child->excute==false) break;
else return true;
}
return true;
}
};
class ParallerNode final: public NonLeafNode{
public:
virtual bool excute() override{
for(auto child:children){
child->excute();
}
return true;
}
};
class ConditionNode :public Node{
std::function<bool()> condition; //前提条件
public:
virtual bool excute()override {
return condition();
}
};
</details>
### **总结:**
对于挂在角色身上的简单的状态转换或是对于npc简单的状态转换,选择状态机去做比较方便
对于一些复杂的决策机制,则使用前置节点-》选择节点去执行逻辑就好。
正常的生产环境都是会选择 状态机 + 行为树结合的方式去实现。