13.1 开源服务器框架
13.1.1 Zinx
Zinx框架的架构有几层?分别是什么?
-
通道层:消息的收发。定义channel类,继承Ichannel,在getnextinputstage函数中返回协议对象。
-
协议层:应用层协议解析。定义protocol类,继承Iprotocol,重写四个函数,两个函数是原始数据和用户数据之间的转换;另两个用来找消息处理对象和消息发送对象。
-
业务层:做业务处理:定义多个Role类继承Irole,重写ProcMsg函数,进行不同处理。
-
消息类:自定义消息类,继承UserData,添加一个成员变量szUserData。
Zinx框架使用的一般流程是什么?
-
TCP Listener监听客户端
-
TCP Factory工厂方法创建一个连接,三层架构的对象都在此时创建,一个业务的通道层、协议层、业务层一一对应
-
GameChannel跟客户端对接收发消息
-
协议层处理粘包,并进行协议解包取出协议消息
-
解出的消息加入队列等待业务层读取
-
业务层读取消息,做相关操作,最后向客户端返回新的消息
Zinx框架的main函数内的大致流程?
-
初始化框架:ZinxKernel::ZinxKernelInit()
-
将通道对象添加到框架,包括标准输入输出、TCP通道等:ZinxKernel::Zinx_Add_Channel(XX)
-
将业务对象添加到框架:ZinxKernel::Zinx_Add_Role(XX)
-
运行框架:ZinxKernel::Zinx_Run()
Zinx框架的责任链模式是什么?
整体的数据流向是一个链式的流向,每一个模块都执行完自己的职责,然后交给下一个模块。
抽象类用来按照某种规律依次处理和传递数据,这就让人想到了责任链模式。
通道类和功能处理类的对象都是整个流程中的环节,将这些环节连起来则形成责任链。
入口->处理者A->发出消息A->处理者B->发出消息B->处理者C->输出。
-
提供handle函数作为链式处理的入口
-
handle内部执行当前环节的处理,并执行下一阶段的处理函数,直到没有下一环节
-
提供internalhandle纯虚函数用来执行本环节处理
-
提供getnext纯虚函数用来获取下一环节
Zinx框架的核心ZinxKernel底层如何实现?
核心是run方法:
-
epoll_wait等待事件触发
-
如果是读事件,触发处理读事件责任链
-
如果是写事件,那就写入到fd,并删掉输出方向的epoll监听
kernel需要设置为单例模式
ZinxHandler类是什么?
整个责任链的入口类。
它的核心函数是handle:
-
当前环节处理:子类重写internel_handle,输入当前消息,获得处理后的消息。每个环节只处理自己该处理的那部分消息
-
获取下一个环节
-
调用下一个环节的handle,实现责任链
通道层的基类Ichannel有何重点?
重点实现 internel_handle 函数。
判断输入消息方向是啥:
-
IO_IN则ReadFd。
-
否则就是传回去,处理来自业务处理的输出,data_sendout。
Zinx框架的TCP通道是如何实现的?有哪些组件?
-
ZinxTcpData:这个类只有一个虚函数GetInputNextStage,其含义与其父类相同。你应该继承这个类并重写此函数,以定义哪个处理程序应处理接收到的字节流。基于此类的对象维护自己的套接字。因此,应在有一个客户端连接后构造该对象。 -
IZinxTcpConnFact:顾名思义,这个类是一个抽象类,用于构造ZinxTcpData对象。你只需重写唯一的虚函数CreateTcpDataChannel,以返回合适的ZinxTcpData对象。 -
ZinxTCPListen:这个类不是抽象类,你可以直接使用它来构造对象。在构造时,应指定TCP监听端口号和IZinxTcpConnFact子类的一个对象。
ZinxTCPListen 继承自 Ichannel,在 init 内创建 socket、bind、listen。在 ReadFd 里 accept,accept成功后用工厂生产一个 ZinxTcpData 并 add 到 kernel。之后这个连接就和 ZinxTcpData 通信。
ZinxTcpData 也继承自 Ichannel,负责收发数据,在 ReadFd 里 recv 并拼接 _input,这个 _input 会随着责任链往下传。在 WriteFd 里 send。
Zinx框架的TCP通道建立连接的流程是什么?
-
ZinxTCPListen监听。 -
若有连接来时,
IZinxTcpConnFact工厂模式创建一个ZinxTcpData。 -
ZinxTcpData负责后续的收发消息。
GameChannel是什么?和TCP通道有何关系?
-
创建GameChannel类继承ZinxTcpData,重写GetInputNextStage函数,将tcp收到的数据交给协议对象解析
-
创建GameChannelFac类用于创建基于连接的GameChannel对象
-
因为玩家是通过tcp连接,所以tcp通道,协议对象,和玩家对象是一对一对一的绑定关系。GameChannel做的事就是在CreateTcpDataChannel函数内绑定协议对象和玩家。
GameMsg类是做什么的?
-
定义GameMsg 类继承UserData 用于存储消息内容
-
每条消息中需要定义消息ID和消息内容,消息内容用protobuf消息封装的父类指针表示
GameProtocol是什么?
-
是协议层,将协议传过来的字符串流做TCP粘包处理,得到消息体。
Zinx框架的定时器是怎么实现的?
ZinxTimerChannel 继承自 Ichannel。
-
Init():创建定时器的文件描述符、设置定时周期。
-
ReadFd():读取超时次数。
-
GetInputNextStage():指向下一个步骤,TimerOutMng。
TimerOutMng 继承自 ZinxHandler。
-
构造函数:创建时间轮。
-
InternalHandle():移动刻度,遍历当前刻度所有节点,指向处理函数或圈数-1;如果圈数小于等于0,取出任务,否则,圈数-1。然后统一待处理超时任务。
Zinx框架如何使用守护进程来监控服务器?
-
fork
-
父进程退出
-
子进程:设置回话ID、重定向3个文件描述到/dev/null,变成守护进程
-
再fork一次
-
子进程一直wait,子子进程启动游戏服务器业务,万一子子进程挂了,子进程就会唤醒,并重启游戏服务器
13.1.2 Skynet
Skynet创建一个lua服务的流程是什么?
-
准备 Lua 服务脚本
首先,你需要编写一个 Lua 脚本文件,比如 myservice.lua,这个脚本定义了该服务启动后要执行的逻辑,包括如何响应消息等。
local skynet = require "skynet" skynet.start(function() skynet.error("My Lua service started") skynet.dispatch("lua", function(session, address, cmd, ...) local f = assert(service[cmd]) skynet.ret(skynet.pack(f(...))) end) end) local service = { hello = function(name) return "Hello, " .. name end }
注意:
-
skynet.start(f)是每个 Lua 服务的入口点,f 是一个函数,在服务初始化时被调用。 -
skynet.dispatch用于注册消息处理函数,通常用来处理其他服务发来的请求。 -
skynet.ret / skynet.pack用于回复消息。
-
通过 Skynet 启动该 Lua 服务
在 Skynet 中,最常用的方式是通过 skynet.newservice 来启动一个新的 Lua 服务,例如在 main.lua 中:
local skynet = require "skynet" skynet.start(function() local myservice = skynet.newservice("myservice") -- 启动 myservice.lua 服务 skynet.call(myservice, "lua", "hello", "World") end)
说明:
-
skynet.newservice("myservice")会去加载当前目录下(或配置的搜索路径中)的myservice.lua文件,并启动为一个独立的 Lua 服务。 -
返回值是该服务的 handle(地址),你可以用它来向该服务发消息。
-
"lua"是默认的消息类型,对应 Lua 服务。
Skynet创建一个lua服务时,框架内部做了什么?
-
解析服务名称,定位 Lua 脚本
-
Skynet 会根据传入的服务名(如
"myservice"),在配置的 服务脚本路径(通常是当前目录或配置指定的路径)中查找对应的.lua文件,比如myservice.lua。 -
如果你使用的是模块化命名(如
"myservice"实际对应"./service/myservice.lua"),Skynet 会根据 配置或者约定 去找到这个文件。
-
-
创建一个新的服务(Service)
Skynet 的核心是用 C 编写的,每个服务(不论是用 C 还是 Lua 编写)在底层都是一个 独立的服务控制块(Service Context),拥有:
-
一个 消息队列
-
一个 独立的执行上下文
-
一个唯一的 地址(handle)
-
创建一个新的服务结构体,分配资源;
-
初始化一个 Lua 虚拟机(Lua State, 即 lua_State);
-
将该 Lua 虚拟机与该服务绑定。
-
-
加载并执行 Lua 脚本
Skynet 通过其内置的 Lua 模块(snlua),调用 Lua API 去:
-
加载
myservice.lua文件(使用loadfile或类似机制); -
执行该脚本,在脚本中通常会调用
skynet.start(function() ... end),这是服务的初始化入口; -
Skynet 会将这个
skynet.start传入的函数作为服务的初始化逻辑,在 Lua 虚拟机初始化完毕后执行它。
-
注册服务到 Skynet 调度器
一旦 Lua 服务脚本执行到 skynet.start,并且初始化完成,Skynet 会把这个服务正式加入调度系统,意味着:
-
它可以接收来自其他服务的消息;
-
它会被 Skynet 的主循环调度执行(当有消息到来,或定时器触发等);
-
它有了自己的 地址(handle),其他服务可以通过该地址向它发送消息。
-
消息分发与处理
当其他服务通过 skynet.call、skynet.send 等 API 向该 Lua 服务发送消息时:
-
Skynet 的底层会将消息投递到该 Lua 服务的 消息队列 中;
-
在适当的时机(由 Skynet 调度器决定),该服务被唤醒,从消息队列中取出消息;
-
如果你使用了
skynet.dispatch,Skynet 会根据消息类型(如"lua")调用你注册的处理函数; -
你可以在 Lua 层处理这些消息,并通过
skynet.ret返回结果(对call而言)。
Skynet的协程模块是怎样的?是对称还是非对称、有栈还是无栈?
Skynet 自身并没有直接暴露一个传统意义上的“协程模块”(比如像 Lua 的 coroutine 库那样让你手动 yield / resume)。Skynet 的“协程”概念,实际上是建立在以下两个核心机制之上的:
-
每个 Skynet 服务(包括 Lua 服务)运行在独立的上下文中,由 Skynet 调度器统一调度;
-
Lua 服务中的“协程”行为,是通过 Lua 语言原生的 coroutine 库 实现的,但它的调度最终仍受 Skynet 控制,且通常以非对称方式工作。
但 Skynet 并没有为 Lua 提供一个自有的、特殊的“Skynet 协程模块”,而是借助:
-
C 层的调度机制(调度器驱动)
-
Lua 原生 coroutine 库(非对称,有栈)
-
Skynet 提供的 API(如 skynet.fork, skynet.timeout, skynet.wait 等)来模拟或辅助协程行为。
Skynet 中的协程(特别是 Lua 服务)是:有栈协程(Stackful Coroutine)
原因:
-
Skynet 的每个 Lua 服务运行在独立的 Lua 虚拟机(lua_State)中,这是一个完整的、有自己调用栈的执行环境;
-
当你在 Lua 服务中使用
coroutine.create创建协程,或者使用skynet.fork创建轻量任务时,它们都运行在有完整调用栈的 Lua 虚拟机环境中; -
当协程 yield(挂起)时,可以保存完整的局部变量、函数调用栈,恢复时能精确回到挂起点 —— 这正是有栈协程的核心特征。
Skynet 中的协程(尤其是通过 Lua coroutine 或 skynet.fork 实现的),整体上是:非对称协程(Asymmetric Coroutine)
原因:
-
在 Skynet 的 Lua 服务中,如果你使用 Lua 原生 coroutine(例如
co = coroutine.create(f),然后coroutine.resume(co)/coroutine.yield()),它是标准的 非对称协程模型;-
协程的 resume 和 yield 不是对等的,必须由外部控制(通常是 Skynet 或你自己的代码)来 resume 它;
-
你不能随意在协程 A 中直接切换到协程 B(除非你自己管理这种关系);
-
-
如果你使用的是 Skynet 提供的工具,如 skynet.fork,它本质上是 Skynet 调度器帮你创建的一个 轻量异步任务(类似协程),但它的调度仍然归 Skynet 管,你无法手动控制它的切换,也不能让协程之间直接互相 yield。
-
没有提供 “任意协程之间可以自由 yield / resume” 的机制 —— 这是对称协程的核心特性,而 Skynet 并未暴露这样的 API。
Skynet的定时器模块底层机制是什么?如何做到异步定时器?
时间轮(Timing Wheel)+ Skynet 主循环调度(不同版本/实现可能略有不同,但思想一致)。
整体工作流程(简化版):
-
你调用
skynet.timeout(ti, func)或类似的定时 API-
Skynet 会将这个任务封装成一个 定时器节点(包含触发时间戳、回调函数等信息)
-
然后把这个节点插入到 定时器管理模块的堆(或轮)中
-
-
Skynet 有一个核心调度循环(主循环,通常在 C 层)
-
这个循环每一帧(或每 10ms,即一个 tick)都会检查:“有没有定时任务到期了?”
-
它会查看定时器堆的 堆顶元素(即最早触发的任务)
-
如果当前时间 >= 该任务的触发时间,则:
-
将该任务取出
-
把该任务的回调函数,转化为一个消息,投递到 Skynet 的某个内部服务(通常是定时器服务,或主服务)
-
或者 直接在调度循环中执行该回调(视具体版本而定)
-
-
-
回调函数被异步执行
-
你传入的 Lua 函数(比如
function()...)会在 Skynet 的调度逻辑中被执行 -
但注意:它往往不是在原来的服务上下文中执行,而是在 Skynet 内部的定时器处理流程中触发,有时是通过消息机制间接调用
-
Skynet中的bootstrap服务主要做了什么?
bootstrap 是 Skynet 启动时配置中指定的、第一个被 Skynet 框架自动拉起(启动)的服务,它负责完成 Skynet 节点的初始化流程,通常是启动后续核心服务(如 main.lua)的“启动器”。
-
初始化运行环境:可以用来做一些初始化工作,比如设置环境变量、加载必要模块、预加载配置等
-
启动主服务(通常是 main.lua):在大多数实际项目中,bootstrap 会负责通过 skynet.newservice("main") 去启动项目的主逻辑服务(比如 main.lua),从而真正启动业务逻辑
-
加载其他必要的基础服务:比如日志服务、集群服务、数据库连接、网关服务等,也可以在这里预先拉起
bootstrap也可作为定制化入口:开发者可以自定义一个 bootstrap.lua,在其中实现特定的启动逻辑,比如多环境适配、安全校验、管理员初始化等。
Skynet的网络的框架流程是什么?底层机制是什么?
Skynet 的网络模块是基于 socket + 事件驱动 + 消息机制 实现的,其核心流程可以概括为:
-
专门的Socket线程一直读网络事件,里面有个epoll。
-
把listenfd挂到epoll红黑树上,同时挂到树上的信息有当前Actor的地址(Actor即一个lua虚拟机)。
-
当有连接来时,epoll事件触发,加到通知队列里,读取epoll树上记录的Actor地址。
-
通知拷贝到Actor消息队列,执行这个Actor的回调函数。
Skynet使用的Actor模型是什么?它和Reactor模型、Proactor模型的区别是什么?
Actor 模型 是一种用于并发计算的数学模型和编程范式,它的基本思想是:一切皆是 Actor,每个 Actor 是一个独立的计算实体,它们之间 不共享内存,只通过异步消息传递进行通信。
Skynet 是一个轻量级的 Actor 模型服务框架,它的核心设计就是围绕 Actor 模型 构建的。具体表现如下:
-
对进程的抽象而非线程(lua虚拟机)。进程有独立运行环境、资源,可以运行环境隔离。线程则有加锁的问题。
-
每个服务(Service)就是一个Actor。
-
服务之间通过消息(Message)通信,消息放到每个Actor专属的消息队列中。
-
没有锁、没有共享状态
区别:
Actor 模型 不是直接处理 I/O 事件的模型,而是关于 并发计算与通信 的一种更高层次的抽象。
它不关心底层是 Reactor 还是 Proactor,可以自由组合。
一个Actor有多个协程,协程的线程安全问题?
协程可以理解为运行在单核模式下,actor同时只会有一个协程运行。
Skynet的Actor之间如何通信?
Socket通信,消息放到每个Actor专属的消息队列中。
Skynet的Actor如何调度?
-
一个全局队列放actors,每个actor又有自己的消息队列。
-
简单的公平调度:每一个线程每次只取全局队列头的actor中的一个消息,执行后又把这个Actor放到全局队列队尾。
-
但如果单个Actor消息过多,里面某些消息饥饿一直不被调度怎么办?
-
让每一个线程带权重,有的线程每次只取一个消息,有的线程直接吃某个Actor的整个消息队列。
-
业务层监控Actor,监测饿死的Actor,把消息负载均衡到其他Actor。
-
为什么说让同一个来源的连接都路由到同一个Actor比较合适?
这样每个Actor可以有缓存。
为什么不所有业务都放到一个Actor?
一个Actor=一个进程、一个线程、多个协程。而Skynet是有配线程池的,你这样一个Actor相当于只使用线程池里一个线程,没有充分调度。
13.1.3 TrinityCore
TrinityCore的网络模型是怎样的?
-
acceptor线程:接收连接、异步日志处理、信号处理。
-
多个network线程:每1000个玩家一个线程,任务push到消息队列。
-
logic/main主线程:取消息队列,处理逻辑,这里是过滤器模式,只处理能处理的东西,对于非主线程处理的逻辑,压到map queue,把任务分配到地图线程池。
-
线程池:处理地图任务,发回到消息队列。每个地图区块用一个线程去处理。(实际设计中,应该每一个地图一个服务器!至少也得是一个进程,不然地图崩了其他也都崩了)
TrinityCore的网络模型用到了哪些设计模式?
-
模版模式:网络基本骨架确定,子类去实现具体逻辑。
-
过滤器模式:main只处理能处理的东西,对于非主线程处理的逻辑,压到map queue。
13.2 开发规范
13.2.1 UML
UML是什么?
面向对象设计主要就是使用UML的类图,类图用于描述系统中所包含的类以及它们之间的相互关系,帮助人们简化对系统的理解,它是系统分析和设计阶段的重要产物,也是系统编码和测试的重要模型依据。
UML图中的每个类,每个框里有哪些信息?
最顶层的为类名,中间层的为属性,最底层的为方法。
"-"表示private、"+"表示public、"#"表示protected。
抽象类无论类名还是抽象方法名,都以斜体的方式表示。
UML图中,继承如何表示?
子类与父类通过带空心三角形的实线来联系。
UML图中,关联如何表示?
箭头连接。
UML图中,聚合如何表示?
在聚合关系中,成员对象是整体的一部分,但是成员对象聚合(Aggregation)关系表示整体与部分的关系。可以脱离整体对象独立存在(内部的子部件没有被析构)。
在UML中,聚合关系用带空心萎形的直线表示。
UML图中,组合如何表示?
组合(Composition)关系也表示的是一种整体和部分的关系,但是在组合关系中整体对象可以控制成员对象的生命周期,一旦整体对象不存在,成员对象也不存在,整体对象和成员对象之间具有同生共死的关系(主体销毁子部件也被析构)。
在UML中组合关系用带实心菱形的直线表示。
UML图中,依赖如何表示?
依赖(Dependency)关系是一种使用关系,特定事物的改变有可能会影响到使用该事物的其他事物,在需要表示一个事物使用另一个事物时使用依赖关系,大多数情况下依赖关系体现在某个类的方法使用另一个类的对象作为参数。
在UML中,依赖关系用带箭头的虚线表示,由依赖的一方指向被依赖的一方。
13.3 消息协议/序列化
13.3.1 自定义消息协议
消息协议是什么?
在游戏服务器中,“消息协议”指的是 客户端与服务器之间,或者服务器各模块之间进行数据通信时所遵循的一种数据格式与交互规范。它定义了:
-
消息的 结构(数据格式)
-
消息的 类型(命令/消息ID)
-
消息的 序列化与反序列化方式
-
消息的 传输方式(TCP/UDP/HTTP/WebSocket等)
简单来说,协议就是双方“如何打包数据、如何识别数据、如何理解数据”的一套约定。
消息协议的分类?
-
文本协议(如 JSON、XML、HTTP Form):可读性强,便于调试;解析相对较慢,占用带宽大。
-
二进制协议:数据体积小,解析效率高;难以直接阅读和调试;需要自定义序列化/反序列化逻辑。
你设计自定义消息协议的设计原则是什么?
-
明确消息类型,每个消息都应该有一个唯一的标识符,用于区分不同的消息类型
-
消息结构清晰、字段精简
-
高效序列化与反序列化
-
支持扩展性
-
考虑安全性
-
考虑网络传输特性:TCP:可靠但可能有粘包问题;UDP:快但不保证顺序和可靠,适合实时性要求极高的情况(如 FPS)
序列化是什么?
序列化 (Serialization)将对象的状态信息转换为可以存储或传输的形式的过程,与之相对应的过程称之为反序列化(Unserialization)。 序列化和反序列化主要用于解决在跨平台和跨语言的情况下,模块之间的交互和调用,但其本质是为了解决数据传输问题。
说几个序列化方法?
XML、json、protobuf、ASN.1抽象语法标记、boost 序列化的类。
13.3.2 protobuf
protobuf是什么?
Protocol Buffer(简称 Protobuf)是Google公司内部的混合语言数据标准,它是一种轻便高效的结构化数据存储格式,可以用于结构化数据串行化,很适合做数据存储或RPC数据交换格式。
Protobuf是一个纯粹的展示层协议,可以和各种传输层协议一起使用,Protobuf的文档也非常完善。google 提供了多种语言的实现:java、c#、c++、go 和 python,每一种实现都包含了相应语言的编译器以及库文件。
protobuf的一般操作流程是什么?
-
准备数据
-
创建 proto 文件:新建
xxx.proto文件。将需要序列化的数据写入该文件,需遵循特定语法格式 -
生成代码:使用
protoc命令将xxx.proto文件编译,生成对应的 C++ 类,包含头文件和源文件 -
使用生成的类:例如
set_XX设置数据,SerializeToString 序列化成字符串,ParseFromString 从字符串反序列化
【必问】protobuf遇到数组类型如何处理?
-
proto 文件里使用 repeated 关键字,表示数组
-
代码里使用 addXX 申请一个数组元素的内存,然后
set_XX,每 set 一次都需要 addXX 一次 -
对于反序列化后的数据,使用 p.XX(0) 访问第 0 个元素,以此类推
protobuf遇到枚举类型如何处理?
-
proto 文件里定义一个 enum 类型,并且protobuf中第一个枚举值必须为 0
-
代码里就像使用普通的类型一样使用枚举类型
【必问】protobuf遇到嵌套类型如何处理?
-
proto 文件里可以定义多个类,也可以 import "XX.proto" 引入外部的类,在一个类中定义 XX xx = 5 表示 XX 类型在第 5 个位置
-
在代码里使用 XX* xx = p.mutable_xx() 来使用嵌套的类型
-
xx->setXX() 来操作这个嵌套类的内部的数据
-
对于反序列化的数据,使用 XX ii = pp.xx() 获得嵌套类,接下去就可以直接获取 ii 内的数据即可
protobuf遇到重名的message如何处理?
-
proto 文件里设置命名空间 package YY
-
在代码里使用 YY::XX* xx = p.mutable_xx() 来使用 YY 命名空间的类型
13.3.3 Json
Json是什么?
-
不是语言,跟语言无关
-
是数据的一种描述形式,以某种格式将数据组织起来
-
写磁盘文件 -> 配置文件
-
网络发送 -> 报文
-
Json数组是什么?
-
类似C++中的数组,
[]表示,元素之间使用逗号间隔。JSON的数组的数据类型可以不同。 -
注意事项:最后一个元素后边不要写逗号,否则会解析失败。
-
可包含的数据类型:
[int, double, float, bool, string, char*, json数组, json对象]。
Json对象是什么?
-
使用
{}表示。 -
分为两部分,
key : value。-
key:必须是字符串,对要存储的数据的描述。 -
value:要保存的数据,数据格式可以有多种:[int, double, float, bool, string, char*, json数组, json对象]。
-
13.3.4 其他序列化方法
为什么XML不常用?
-
代码操作麻烦
-
冗余数据多
为什么boost::serialization不常用?
-
只适用于 C++。
13.4 部署
13.4.1 跟房建房
QT登录器的简单逻辑是什么?
-
用户点击跟房后,发送输入的用户名和密码和要跟随的房间号给登陆服务器
-
服务器会回复登陆结果和跟房结果
-
若登陆成功且跟房成功则启动游戏程序否则弹出提示信息
-
用户点击建房后,发送用户名密码给登陆服务器
-
若登陆成功且建房成功则提示房间号并启动游戏
-
用户点击注册后弹出浏览器并显示注册页面
13.4.2 分布式部署
你的分布式部署有几台机器?怎么打包的?
-
准备4台机器,一台当做客户端,一台为主服务器(登录服务器,处理登录请求,上面有Nginx和Redis),两台为从服务器,用于启动游戏进程
-
编写QT客户端,有注册、建房、跟房功能
-
将游戏进程打包成游戏服务器镜像
简易的用户注册流程是什么?
-
用户点击QT登录器的注册按钮后,弹出浏览器并显示注册页面
-
用户填完注册信息,信息发到Nginx,转发到注册CGI,不要传密码明文,传MD5
-
CGI解析http参数后先执行“确认用户存在”的脚本,用户不存在则注册用户,信息存到一个磁盘文件里
-
原路发回http到浏览器,告诉用户注册是否成功
【必问】登录+创建游戏房间的流程是什么?
-
用户在QT界面输入用户名、密码,点击建房
-
QT拼接登录请求json
-
用QT网络库发请求到登录服务器Nginx,转发给对应CGI
-
CGI解析用户名密码以及请求是建房还是跟房,此处是建房
-
CGI执行脚本确认用户名密码是否正确
-
设置一个全局变量存储房间号信息,因为不同机子上端口可能重名,用于全局容器管理;该信息同样需要存入到Redis
-
在确保负载均衡的情况下选择一台从服务器的IP
-
使用Redis发布订阅机制,向从服务器发布建房信息(IP+房间号),并立刻订阅容器端口信息
-
从服务器早已异步订阅建房信息
-
收到主服务器的建房发布时,先确认主服务器传来的消息里指定的IP是否是自己的IP
-
若是,则执行建房脚本创建容器,将房间号存入环境变量,并有一个守护进程监控容器情况
-
守护进程若检测到容器已经退出,读取环境变量中的房间号,删除Redis里的房间号信息
-
-
用管道得到建房的容器端口
-
使用Redis发布订阅机制,同步的方式向主服务器发布新创建的容器端口信息
-
-
由于主服务器已经订阅容器端口信息,所以就会收到从服务器发来的容器端口信息
-
CGI将从服务器IP和容器端口信息打包在json里http发回QT客户端
-
-
QT解析发回的http响应里的json,获取IP+容器端口号
-
启动Unity程序,指定其连接的IP+端口号
【必问】登录+跟房的流程是什么?
-
用户在QT界面输入用户名、密码、房间号,点击跟房
-
QT拼接登录请求json
-
用QT网络库发请求到登录服务器Nginx,转发给对应CGI
-
CGI解析用户名密码以及请求是建房还是跟房,此处是跟房
-
CGI执行脚本确认用户名密码是否正确
-
在Redis上查询房间号信息,没有房间号则立刻返回错误
-
在Redis上根据你的房间号信息,获得从服务器的IP和端口
-
CGI将从服务器IP和容器端口信息打包在json里http发回QT客户端
-
-
QT解析发回的http响应里的json,获取IP+容器端口号
-
启动Unity程序,指定其连接的IP+端口号
你的登录服务器用的是什么?
Nginx。
你的跟房系统如何判断房间是否存在?
创房的时候,把房间号、IP端口存入到Redis,记录一下。
跟房的时候在Redis上查询即可。
主从服务器如何通信来创建和销毁房间?
使用Redis发布订阅机制,向从服务器发布建房信息(IP+房间号)。
从服务器早已异步订阅建房信息。收到主服务器的建房发布时,先确认主服务器传来的消息里指定的IP是否是自己的IP,若是,则执行建房脚本创建容器,将房间号存入环境变量,并有一个守护进程监控容器情况。
使用Redis发布订阅机制,同步的方式向主服务器发布新创建的容器端口信息。
13.5 业务模块
13.5.1 登录模块
你登录模块用的什么算法?SRP-6是什么?
SRP-6(Secure Remote Password protocol version 6) 是一种用于安全远程密码验证的协议,属于 SRP(Secure Remote Password) 协议家族中的一个具体版本。它主要用于在客户端和服务器之间进行 安全的用户身份认证,而 无需在网络上明文传输密码或密码的哈希值,从而有效防止中间人攻击、重放攻击以及密码泄露。
你的登录模块的整体流程是什么?
-
webserver:负责注册。注册信息放到DB。
-
client先登录authentication server,认证成功了,再连上world server进行战斗打怪。
-
不传输明文密码,防止被劫持。
注册阶段的流程是什么?
-
注册阶段:
-
客户端:发送用户名和密码。
-
服务器:生成随机值 s,再用这个随机值 s 以及用户名密码生成 v,把 v 存到数据库。
-
认证阶段的流程是什么?
-
认证阶段:
-
客户端:用户尝试登录时,客户端向服务器发送用户名,请求进行身份验证。
-
服务器:服务器根据用户名查找之前注册时存储的相关信息,发回固定大数 N,固定数 g,以及随机盐值 s。
-
客户端:客户端接收到服务器的响应后,生成随机数 a,计算公钥 A,把 A 发送到服务器。
-
服务器:生成随机数 b 和干扰项 u,计算公钥 B,把 B 发回到客户端。
-
客户端:这时候已经有 A、B,就可以计算 session key K(密码 p 在这一步被暗含),有了 K 之后就可以继续计算 M,称为 M1。
-
服务器:收到 M1 之后,服务器自己计算 M1,服务器端的 M1 的计算逻辑中的 S 部分和客户端的算法稍有不同(S = (A - v^u)^b,验证器 v 中暗含密码 p)。并将两个 M1 进行比较,若相同则密码正确,不相同则错误。若正确,计算 M2 = H(A, B, K) 发回客户端。
-
客户端:最终验证 M2,若相同,流程结束。
-
已经认证成功,登录world server的流程是什么?
-
已经认证成功,登录world server:
-
客户端:服务器在发回 M2 的时候可能会发回服务器列表让你自己选,或者是随机负载均衡一个服务器给你。这时候你只需要和 world server建立连接。
-
服务器:验证客户端 IP 地址(只有三次握手也能验证),生成一个随机 seed,发回 seed。
-
客户端:收到 seed 后,自己生成一个随机值 lc,并用 lc、seed、用户名、客户端的 session key 联合起来计算一个哈希值 digest,发过去。
-
服务器:用 lc、seed、用户名、服务器端的 session key 计算哈希值,并和 digest 作比较验证,验证成功则可以继续交互。
-
13.5.2 地图模块
地图中的动态数据 vs 静态数据有什么区别?
-
动态数据:地图上行动的怪物、玩家。
-
静态数据:不会动的NPC、一个房子等。这些一般是客户端或者策划去弄。在哪个格子就加载哪个格子的数据。
地图区块生命周期你怎么设计?
第一个玩家进入并加载(invalid)->加载完成(active)->一个玩家都没了(idle)->过了很久依然没有玩家(remove)。这些状态都是有定时器监控的。
AOI算法是什么?
AOI 是指一个特定区域,在该区域内的游戏对象(如玩家、NPC、道具等)需要被其他对象感知或交互。例如:
-
玩家只能看到或交互他周围一定范围内的其他玩家或 NPC
-
只有在某个范围内的单位才需要同步位置、状态等信息
为什么需要AOI算法?
-
降低服务器负载:避免将全地图所有对象的信息都同步给每个玩家
-
减少网络流量:只同步相关对象的数据
-
提升游戏性能与体验:确保玩家只关心他视野或影响范围内的内容
基于网格Grid、哈希表的AOI算法是什么?
原理:
-
将整个游戏地图划分为多个固定大小的 格子(Grid Cell)
-
每个对象根据其坐标属于某一个或几个格子
-
每个格子维护一个列表,记录当前在这个格子里的对象
-
当需要查找某个对象的“兴趣区域”时,只需检查它所在格子及相邻格子中的对象
哈希表:
-
通常会为每个格子分配一个唯一的 ID(比如 (x,y) 坐标),然后使用 哈希表(Hash Map) 来存储:
格子ID -> [对象列表] -
这样可以通过对象坐标快速定位到对应的格子,进而找到相邻格子,实现高效查询
-
哈希表在这里的作用是快速索引格子及其对象集合
基于四叉树的AOI算法是什么?
原理:
-
四叉树是一种用于 2D 空间分割 的树状数据结构
-
根节点代表整个地图区域,每层节点不断将区域 均分为四个象限(子区域),直到满足某个终止条件(比如节点内对象数量小于阈值,或达到最小区域大小)
-
每个节点保存落在该区域内的对象列表
-
查询某个对象的 AOI 时,只需要搜索与该对象位置相交的少数几个四叉树节点,即可找到潜在的“兴趣对象”
基于八叉树的AOI算法是什么?
原理:
-
八叉树是四叉树在 3D 空间上的扩展
-
根节点表示整个 3D 区域,每个节点将空间递归地划分为 八个子立方体(八分体)
-
用于管理 3D 游戏场景中的对象分布,比如开放世界 RPG、飞行类、VR 游戏等
AOI有哪些算法模型?
-
拉模型(Pull-based / 基于查询)
-
每个对象定期主动去“查询”自己周围的对象
-
例如:每帧根据自己坐标,查找附近格子/区域中的对象
-
通常与网格或四叉树等空间索引配合使用
-
-
推模型(Push-based / 基于通知)
-
当某个对象移动时,服务器主动计算它进入或离开了哪些其他对象的 AOI,并推送通知(如进入视野、离开视野等事件)
-
需要维护更复杂的数据结构来跟踪谁在谁的 AOI 中
-
-
混合模型
-
结合两者优势,比如用拉模型做常规查询,用推模型处理关键事件(如进入战斗范围等)
-
AABB碰撞检测算法是什么?
AABB(Axis-Aligned Bounding Box,轴对齐包围盒),是指一个和坐标轴对齐的矩形(2D)或长方体(3D)边界框,用来粗略地包围某个游戏对象(如玩家、子弹、地图块、NPC等)。
-
“轴对齐”意味着这个盒子没有旋转,它的边与世界坐标的 X、Y(和 Z)轴平行
-
通常用两个点表示:最小点(min, 左下/前)和最大点(max, 右上/后),或者用 (x_min, y_min, z_min) 和 (x_max, y_max, z_max)
为什么我们需要AABB算法?
-
计算非常高效(只涉及简单的数值比较,没有三角函数或旋转计算)
-
适合用作第一层快速筛选(Broad Phase),排除明显不相交的对象,减少精确碰撞检测的计算量
如何判断AABB是否相交?碰撞检测原理是什么?
2D 情况下:
两个 AABB A 和 B,如果满足:
-
A.min.x <= B.max.x 且 B.min.x <= A.max.x (X 轴有重叠)
-
A.min.y <= B.max.y 且 B.min.y <= A.max.y (Y 轴有重叠)
则这两个 AABB 相交(发生碰撞),否则不相交。
3D 情况类似,再多判断一个 Z 轴。
dynamic tree是什么?如何与AABB配合?
它本质上是一种 树状结构(通常是二叉树,如 AABB Tree 或 BVH),树的每个节点存储一个 AABB,这个 AABB 是其子节点 AABB 的合并包围盒,叶子节点则存储实际的物体或对象的 AABB。
-
每个叶子节点存储一个对象的 AABB
-
比如一个玩家、一个子弹、一个 NPC,它们的 AABB 作为树的叶节点。
-
-
内部节点存储的是其子节点 AABB 的合并(包围盒)
-
即父节点的 AABB 能完全包含它的所有子节点的 AABB。
-
-
插入、删除、更新
-
当某个对象移动了,导致它的 AABB 发生变化,Dynamic Tree 会:
-
更新该叶子节点的 AABB
-
逐级向上调整父节点的合并 AABB
-
可能还会触发 树的再平衡(rebalancing)
-
-
-
查询:找出与某个 AABB 相交的所有对象
-
比如你想知道:“当前这个技能范围(一个 AABB)覆盖了哪些敌人?”
-
你只需:
-
将技能的 AABB 作为查询条件,递归遍历树
-
只要某个节点的 AABB 与查询 AABB 相交,就继续深入其子节点
-
直到访问到叶子节点,获取真实的游戏对象
-
-
这样可以避免遍历所有对象,只检查可能相交的候选对象。
-
A*算法是什么?有什么用?
-
A*(A-Star)是一种启发式搜索算法,用于在图中(比如网格地图、导航网格)找出一个从起点到终点的 “代价最小” 的路径,它通过综合考虑 “已走过的代价” 和 “预估剩余代价” 来高效地选择搜索方向。
-
A* 是 Dijkstra 算法的优化版,在 Dijkstra 只考虑“已经走过的距离”的基础上,引入了一个“预估到目标的代价”(启发式函数,Heuristic),从而引导搜索方向更偏向目标,大幅提升效率。
A*算法的节点有哪几个参数?
-
g:从起点到当前节点的实际代价(比如走的步数、距离)
-
h:从当前节点到终点的预估代价(启发式值),比如曼哈顿距离、欧几里得距离
-
f = g + h:总评估代价,A* 就是优先扩展 f 值最小的节点
A*算法有哪些启发式函数?
-
曼哈顿距离(Manhattan)
-
欧几里得距离(Euclidean)
-
对角线距离(Diagonal)
A*算法的流程是什么?
-
初始化:
-
将起点加入 开启列表(Open List,优先队列,按 f 值排序)
-
初始化起点的 g=0, h=启发式估算, f=g+h
-
关闭列表(Closed List)用于记录已经处理过的节点
-
-
循环直到找到终点 或 开启列表为空:
-
从 Open List 中取出 f 值最小 的节点作为当前节点
-
如果当前节点是终点,则 路径找到,回溯 parent 得到路径
-
否则,将当前节点放入 Closed List,表示已处理
-
遍历当前节点的所有相邻节点(邻居):
-
如果邻居不可行走 或 在 Closed List 中,跳过
-
计算从起点经过当前节点到邻居的 g_new
-
如果邻居不在 Open List 中,或者 g_new 比之前记录的更小:
-
更新邻居的 g、h、f 值
-
设置邻居的 parent 为当前节点
-
如果邻居不在 Open List,将其加入
-
-
-
-
结束:
-
如果 Open List 为空但未找到终点 → 路径不存在
-
13.5.3 技能模块
如何打造一套扩展性强又好用的技能框架?
后期易扩展。一般在项目DEMO阶段,战斗系统就要开始搭建,但起步阶段又不可能就把所有的技能效果规划好,尤其是内容游戏,你永远不知道战斗策划下一个“奇怪”创意是什么。
功能可复用性。目的是减少开发成本。比如两个A、B技能,技能效果都是造成减速,只是触发条件不同,这时候无需做个全新的减速技能,只要新增触发条件类型即可。聪明的老师们已经发现了,这种思路其实是将一个技能拆分成多个模块,只实现缺失的模块,然后和已有的其他模块快速拼装成一个新的技能。
配置时间成本低。技能相关字段多如牛毛,如果所有字段只放在同一张表上,必然会降低翻看速度、错误排查效率。建立多个分表非常重要,如技能主表、BUFF表、伤害表、动效表现表。
一个技能释放的步骤是什么?
-
能否释放
-
释放类型
-
主动
-
被动
-
自动
-
主动技能需要玩家手动操作释放;被动技能无需玩家主动触发,在满足特定条件时自动生效;自动技能则会在特定情况下由系统自动释放
-
-
释放条件
-
消耗(如魔法值、能量值等资源的消耗)
-
CD(冷却时间,即技能使用后需要等待一段时间才能再次释放)
-
充能次数(某些技能需要积累一定次数的充能才能释放)
-
-
-
对谁放
-
范围选择
-
仅对施法者自己
-
指向指定目标
-
只要选定,目标跑到天涯海角都会结算效果
-
目标达成一定条件效果会失效
-
-
只能指向特定目标,不能由玩家选择
-
例如只有身上带有XX标记的敌人
-
例如只能指向战力最高的敌人
-
-
指向一个路径
-
只对第一个目标/第一个目标及其附近/对整个路径
-
伤害逐渐缩减/伤害经过目标后衰减/伤害不衰减
-
可对友方影响/只对敌方影响
-
环境影响,路径dot伤害
-
-
指向一个区域
-
区域内的所有敌方/区域内某个敌人(例如血量最低?)/可对友方影响
-
环境影响,区域dot伤害
-
-
指向以某个目标为中心的区域
-
该作用区域一经确定就不变
-
该作用区域一直跟随,中心点以这个目标为中心
-
-
-
目标选择
-
在确定的范围内,进一步筛选具体的目标
-
目标筛选规则
-
目标数量限制
-
生效顺序等内容
-
例如,有些技能会优先选择敌方血量最少的目标,或者按照距离玩家的远近顺序生效等
-
-
-
效果
-
伤害计算
-
计算技能对目标造成的伤害数值
-
伤害类型(如物理伤害、魔法伤害等)
-
战斗公式(用于计算伤害的具体算法,通常会考虑攻击力、防御力、暴击率等多种因素)
-
伤害段数(技能可能造成多次伤害)
-
-
buff
-
技能释放后给目标或自身施加的增益或减益效果
-
buff分组(对不同类型的buff进行分类管理)
-
buff类型(如攻击加成、防御提升、减速等)
-
buff叠加(多个相同或不同buff的叠加规则)
-
持续时间(buff效果持续的时长)
-
-
表现
-
技能释放时的视觉和听觉表现,如技能特效、音效等,增强游戏的沉浸感和趣味性
-
-
一个技能的生命周期是什么样的?
-
出生:角色释放技能
-
WorldObject:玩家、怪物……
-
GameObject:静态组件,例如一个门……
-
DynamicObject:继承自WorldObject,技能中的临时对象,例如箭雨、火堆……
-
Unit:战斗对象 -> 技能绑定在unit上
-
-
结束:使用定时器
-
技能拥有状态:准备,释放中,完成
-
如果完成,定时器在帧同步的时候delete技能,技能的析构函数删除unit对象的这个技能
-
技能一般怎么存储?
-
技能死信息
-
配置表
-
当你自创一条技能,要同时修改配置表和数据库
-
-
玩家的技能
-
数据库
-
登录的时候直接从数据库拉取信息,如果大型项目还可能有配置中心
-
13.5.4 背包模块
让你设计游戏物品的基类,你怎么设计?使用哪些设计模式?
在游戏服务器中,物品(Item) 是一个非常核心且种类繁多的概念,比如装备、消耗品、任务物品、材料等。为了能够统一管理这些不同类型的物品,我们需要设计一个物品基类(ItemBase),然后通过继承或组合的方式扩展出各种具体物品类型。
-
基础属性
-
可堆叠数量、是否必须和号主绑定禁止交易……
-
其他易变信息需要读配置表,这里要配置表的ID
-
配置表里有什么?ID、名称、描述、种类、模型(贴图)ID、售价……
-
-
-
基础方法
-
使用物品
-
-
使用的设计模式:
-
模板方法模式(Template Method Pattern):
-
基类中定义通用的接口如
Use(),子类可以重写这些方法实现具体的行为。基类控制流程,子类实现细节。
-
-
工厂模式(Factory Pattern,可选):
-
如果物品创建逻辑复杂,可以使用工厂来创建不同类型的物品对象,便于扩展和维护。
-
-
组件模式(Component Pattern,进阶设计):
-
更灵活的方式是将物品的不同功能拆分为多个组件(如 UsableComponent, EquipableComponent),通过组合方式构建物品。这种方式更符合现代游戏架构,也更容易扩展。
-
-
让你设计存储游戏物品的格子,你怎么设计?
每个格子(Slot) 是背包中的一个位置,用于存放一个物品堆叠(或单个物品)。一个格子需要记录:
-
当前存放的物品(指针或ID)
-
当前数量
-
最大堆叠限制(通常来自物品模板)
-
基础方法
-
添加物品到格子
-
移除物品
-
让你设计格子的容器——背包,你怎么设计?
背包(Inventory/Bag) 是由多个 ItemSlot(格子) 组成的容器,通常具有以下功能:
-
固定或动态数量的格子
-
支持添加、移除、移动物品
-
查询某个物品的数量 / 是否存在
-
序列化与反序列化(保存/加载)
-
可能的扩展:分类(装备、消耗品)、排序、筛选等
使用的设计模式:
-
组合模式(Composition):
-
背包由多个格子(ItemSlot)对象组合而成,每个格子是一个组件。
-
-
管理器模式(Manager Pattern):
-
Inventory 作为物品的管理器,负责物品的增删改查、逻辑控制。
-
-
迭代器模式(Iterator Pattern,可选):
-
如果你希望外部可以遍历背包中的物品,可以实现迭代器,或者提供查询接口。
-
-
策略模式(Strategy Pattern,可选):
-
可以为不同的背包类型(如普通背包、装备栏、交易栏)定义不同的放置/操作策略。
-
-
享元模式(Flyweight Pattern,可选):
-
如果有大量相同物品,可以用共享对象减少内存开销,比如共用 ItemBase 数据。
-
让你设计装备系统,你怎么设计?使用哪些设计模式?
-
自身属性:装备ID、所属位置、等级
-
给人物的属性增强:加攻击力
-
特殊效果:类似被动技能
-
套装效果:每个装备都有所属套装的ID,有一个套装管理器。玩家调用equip方法,就会触发套装管理器的on_equipment_changed,在这里面读取你穿了啥装备,记录到一个map,统计你哪个套装ID有几件装备(例如你穿了4件,套装A里有2件,B里1件,C里一件,那就只触发A的效果)。
-
接着,根据套装ID,读配置表(读出套装要求、对应的奖励类),若达到配置表里的套装要求(例如2件),然后策略模式触发奖励类里的统一接口。
-
13.5.5 AI模块
AI模块之状态机是什么?
状态机(State Machine) 是一种计算模型或设计模式,用于描述一个对象(比如游戏中的角色、NPC、敌人等)在其生命周期中可能处于的不同状态,以及在这些状态之间如何切换。
一个典型的状态机有哪些要素?
-
状态(States)
-
表示对象可能处于的某种“模式”或“行为”。
-
例如:巡逻、追击、攻击、逃跑、死亡等。
-
-
事件 / 条件(Transitions / Conditions)
-
决定何时从一个状态切换到另一个状态。
-
例如:如果“玩家进入视野”,则从【巡逻】状态切换到【追击】状态。
-
-
行为 / 动作(Actions)
-
每个状态下,对象会执行特定的行为。
-
例如:在【攻击】状态下,NPC会播放攻击动画并尝试造成伤害。
-
-
状态管理器(State Manager / Context)
-
负责维护当前状态,并处理状态之间的切换逻辑。
-
通常是一个“控制器”或“AI控制器”类,持有当前状态对象。
-
有限状态机是什么?
这是最传统、最常用的状态机形式,状态数量有限,且每个时刻只处于一个状态。
特点:
-
每个时间点只有一个 当前状态;
-
通过 条件判断 决定是否切换到其他状态;
-
结构清晰,适合控制相对简单、逻辑明确的行为。
分层状态机是什么?
为了应对更复杂的逻辑,人们还发展出一些 FSM 的变种:
-
分层状态机:状态可以嵌套,比如“战斗状态”下再分“攻击”、“防御”、“撤退”等子状态;
-
并行状态机:允许同时处于多个状态(比如一边移动一边播放受伤动画);
-
状态栈(State Stack):可以压入/弹出状态,用于实现“临时状态”或“中断与恢复”。
设计一个简单的怪物状态机?
状态切换逻辑可能是这样的:
-
巡逻 → 追击:当玩家进入检测范围;
-
追击 → 攻击:当距离足够近;
-
攻击 / 追击 → 受伤:当受到伤害;
-
任何状态 → 死亡:血量 <= 0;
AI模块之行为树是什么?
行为树(Behavior Tree) 是一种用于控制 AI 决策和行为流程的树状结构模型,它将 AI 的行为拆分成多个小的、可复用的节点(Nodes),这些节点以树形结构组织起来,AI 从根节点开始,自顶向下、递归地执行,根据节点的逻辑决定下一步该做什么。
行为树的节点状态有哪几种?
Success(成功):节点成功完成,例如:找到玩家、任务达成
Failure(失败):节点执行失败,例如:没找到玩家、条件不满足
Running(运行中):节点正在执行中,尚未完成,例如:正在移动、技能释放中
行为树的节点类型哪几种?
-
Composite(组合节点)——控制多个子节点的执行逻辑
-
Sequence(序列):依次执行子节点,全部成功才算成功,任一失败则整体失败
-
Selector(选择器):依次执行子节点,直到某一个成功则整体成功,全失败才失败
-
Parallel(并行):同时执行多个子节点
-
Decorator(装饰器):修饰某个子节点,比如取反、循环、条件限制等
-
-
Task(任务节点 / 叶子节点)——执行具体行为
-
Decorator(装饰器节点)——为节点附加条件或控制
-
Blackboard(黑板)——共享数据区
行为树的执行流程是什么?
-
从根节点开始执行
-
根据组合节点逻辑(如 Sequence / Selector),递归执行子节点
-
叶子节点(Task)执行具体行为,返回 Success / Failure / Running
-
根据返回值,决定父节点以及整棵树的后续行为
-
行为树持续运行(如在每帧或定时更新中驱动)
AI决策的事件埋点一般埋在哪里?
-
战斗:进入战斗、脱离战斗、选择目标、选择技能
-
视野:进入视野
-
移动:开始移动,到达目的地,中断移动
-
对话
13.5.6 副本模块
副本是什么?
-
额外的地图
-
单独房间内的玩法
-
夺旗玩法
-
资源点抢夺
-
大战场PVP
-
攻防玩法
-
组队打boss
-
……
-
副本的数据库设计?
-
数据库设计
-
ID
-
人数限制
-
出生点及其他战场固定信息
-
如何使用模板方法扩展副本?
-
抽象类:battleground基类,不同的副本继承自这个类
-
重要函数:update 帧更新
-
在 update 里:步骤1 -> 步骤2 -> …… 每个战场总体流程固定,但是具体的某个步骤不同
-
每个子类重写的是这些步骤,子类里的各个步骤都是 protected/private,只能是父级的 update 调用,不要乱调用
-
前状态:判断是否参与帧更新
-
这些步骤是什么?-> 副本状态机更新
-
匹配实力相当的对手(子类实现)
-
人够了进入匹配队列,创建战场实例
-
-
游戏进行中 -> 这又是一个子算法骨架,里面又需要实现死亡结算、重生结算、物品掉落、是否卡bug到地图外……
-
等待大家退出,到时间自动踢
-
-
后状态:一些定时器逻辑
-
13.5.7 反外挂模块
常见外挂有哪些种类?
-
内存修改类(Memory Hack / Cheat Engine)
-
原理:通过修改游戏客户端内存中的数据(如血量、金币、坐标等)来获得优势。
-
例子:无限生命、无限弹药、修改角色属性值。
-
-
透视类(Wallhack / ESP)
-
原理:通过读取游戏内存或网络数据包,获取本不应显示的信息,如墙壁后其他玩家的位置、血量等。
-
例子:墙透(Wallhack)、人物高亮(ESP)、道具透视。
-
-
加速类(Speed Hack / Fly Hack)
-
原理:修改角色移动速度参数,或者直接控制角色位置,实现瞬移、飞行、超高速移动。
-
例子:角色超速移动、穿墙飞行、地图瞬移。
-
-
自动脚本类(Bot / 宏 / 挂机)
-
原理:利用脚本模拟玩家操作,实现自动打怪、自动拾取、自动瞄准(Aimbot)、挂机升级等功能。
-
例子:挂机刷怪、自动瞄准射击、自动答题/任务机器人。
-
-
数据包篡改/重发(Packet Cheat / Spoofing)
-
原理:拦截并修改客户端与服务器之间的通信数据包,伪造操作或状态。
-
例子:伪造攻击指令、篡改移动位置、模拟登录或交易。
-
服务器检测外挂的一般方法?
-
逻辑校验
-
关键数据服务端计算:如伤害计算、命中判定、移动轨迹等必须在服务端进行,客户端只做展示。
-
参数合理性检查:例如移动速度、技能冷却时间、位置变化是否符合游戏规则。
-
状态同步校验:客户端上报的状态(如坐标、动作)需与服务器预期一致,否则视为可疑。
-
-
行为分析
-
玩家行为建模:通过机器学习或规则引擎分析玩家行为模式,识别异常(如秒杀、瞬移、高频攻击)。
-
异常操作检测:如移动速度过快、攻击距离超出正常、频繁复活/无敌等。
-
时间与频率限制:对关键操作设置合理频次上限,防止脚本滥用。
-
-
数据包检测
-
数据包合法性校验:检查数据包格式、字段范围、来源合法性。
-
防重放攻击:检测是否重复发送某个数据包以作弊。
-
加密与签名:对关键通信数据进行加密和签名,防止篡改和伪造。
-
-
设备与账号行为关联
-
设备指纹识别:识别是否同一设备多账号登录、是否存在批量脚本行为。
-
账号行为画像:新账号异常成长、爆装备、升级过快等。
-
登录与操作地理位置分析:如频繁跨国登录、异地操作等。
-
13.6 高性能
13.6.1 同步机制
状态同步是什么?
状态同步(State Synchronization) 是指服务器计算并维护游戏世界的完整状态(如角色位置、血量、技能效果等),然后定期将当前的游戏状态广播给所有客户端。客户端接收到服务器的状态后,直接更新本地显示。
特点:
-
服务器是权威,客户端只是显示服务器下发的状态。
-
客户端不需要计算复杂的游戏逻辑,只需要渲染服务器传来的数据。
-
适用于对公平性要求高、逻辑复杂、网络延迟影响较大的游戏(如 MMO、FPS)。
缺点:
-
延迟较高,因为依赖服务器的计算和网络传输。
-
服务器压力大,需要计算所有游戏逻辑。
帧同步是什么?
帧同步(Frame Synchronization / Lockstep) 是指服务器只负责同步玩家的输入(如按键、技能释放),而不同步游戏状态。所有客户端基于相同的初始状态和相同的输入序列,在本地独立计算游戏逻辑,最终达到一致的游戏表现。
特点:
-
服务器只同步玩家的输入(如操作指令),不计算游戏逻辑。
-
所有客户端按照相同的逻辑帧(固定时间间隔)执行相同的输入,确保结果一致。
-
适用于对实时性要求高、逻辑相对简单的游戏(如 MOBA、RTS)。
缺点:
-
对网络延迟敏感,如果某个客户端的输入延迟过高,可能导致卡顿。
-
如果客户端计算逻辑不一致(如浮点数精度问题),可能导致不同步(Desync)。
帧同步的数据流程说一下?
-
玩家输入采集:
-
每个客户端在每一帧(或固定时间间隔)采集玩家的输入(如移动、技能释放)。
-
-
输入发送到服务器:
-
客户端将本地的输入数据(如按键、技能ID)发送给服务器。
-
-
服务器广播输入:
-
服务器收集所有客户端的输入,并按帧顺序打包,广播给所有客户端。
-
-
客户端本地计算:
-
每个客户端收到服务器的输入后,按照相同的逻辑帧顺序执行所有玩家的输入,计算游戏状态。
-
-
渲染显示:
-
客户端根据本地计算的结果渲染游戏画面。
-
帧同步一次同步时间怎么选?
-
16ms(60FPS):适用于高实时性游戏(如 MOBA、RTS),但网络要求较高。
-
33ms(30FPS):平衡实时性和网络延迟,适用于大多数帧同步游戏。
-
50ms~100ms:适用于对实时性要求较低的游戏(如回合制、卡牌游戏)。
帧同步选TCP还是UDP?
帧同步通常使用 UDP,而不是 TCP。
原因:
-
TCP 的可靠性机制(重传、确认)会导致延迟增加,而帧同步对实时性要求高,UDP 更适合。
-
UDP 允许丢包,帧同步可以通过冗余或插值补偿,而 TCP 的重传可能导致逻辑帧等待,影响同步。
-
UDP 更轻量,适合高频小数据包(如玩家输入),而 TCP 的连接管理开销较大。
优化方式:
-
使用 可靠 UDP(如 KCP、QUIC) 或 自定义重传机制 来保证关键输入不丢失。
-
对于非关键输入(如非战斗状态的操作),可以允许少量丢包。
状态同步 vs 帧同步有什么区别?
| 对比项 | 状态同步 | 帧同步 |
| 同步内容 | 同步游戏状态(位置、血量等) | 同步玩家输入(按键、技能) |
| 计算逻辑 | 服务器计算,客户端只渲染 | 所有客户端独立计算 |
| 服务器角色 | 权威,计算所有逻辑 | 只同步输入,不计算逻辑 |
| 网络依赖 | 依赖服务器计算和网络传输 | 依赖输入同步,对延迟敏感 |
| 适用游戏 | MMO、FPS、RPG | MOBA、RTS、卡牌 |
| 同步频率 | 较低(如 10~30Hz) | 较高(如 30~60FPS) |
| 容错性 | 服务器容错高,客户端可容错 | 所有客户端必须严格同步,否则不同步 |
状态同步、帧同步分别适用于什么类型的游戏?
状态同步:MMO、FPS、RPG
帧同步:MOBA、RTS、卡牌
说一下服务器端的延迟补偿、丢包处理、回滚策略?
延迟补偿(Lag Compensation):
问题: 玩家操作时,由于网络延迟,服务器可能无法立即处理,导致命中判定不准确(如 FPS 游戏射击延迟)。
解决方案:
-
回溯(Rewind):服务器根据玩家的当前位置和延迟时间,计算过去某一时刻的位置,进行命中判定。
-
预测(Prediction):客户端预测服务器可能的响应,提前显示结果,服务器后续修正。
-
插值(Interpolation):平滑过渡玩家位置,减少卡顿感。
适用场景: 状态同步的 FPS、TPS 游戏。
丢包处理(Packet Loss Handling):
问题: 网络丢包可能导致输入丢失,影响游戏同步。
解决方案:
-
冗余发送:客户端多次发送关键输入(如技能释放),确保服务器至少收到一次。
-
插值补偿:如果某个输入丢失,客户端可以用上一帧的输入进行平滑过渡。
-
UDP + 自定义重传:对于关键输入(如战斗操作),使用可靠 UDP 或自定义重传机制。
-
状态回滚:如果丢包导致逻辑不一致,服务器可以回滚到上一个稳定状态并重新同步。
适用场景: 帧同步、实时竞技游戏。
回滚策略(Rollback):
问题: 如果某个客户端的输入延迟或错误,可能导致游戏状态不一致,需要回滚到正确状态。
解决方案:
-
状态快照(Snapshot):服务器定期保存游戏状态的快照,出现问题时回滚到最近正确的状态。
-
输入重放(Input Replay):从某个时间点重新计算所有输入,确保所有客户端同步。
-
乐观预测 + 修正:客户端先预测结果,服务器后续修正(如 MMO 游戏的移动预测)。
适用场景: 帧同步(如 RTS)、状态同步(如 MMO)。
13.6.2 全球服架构
全球服是什么?
全球服(Global Server / Worldwide Server) 是指 一款游戏在全球范围内仅使用一套服务器(或少量服务器集群),让来自不同国家/地区的玩家在同一游戏世界内进行交互,而不是为每个地区(如北美、欧洲、亚洲)单独部署服务器。
特点:
✅ 玩家互通:全球玩家在同一服务器,可以跨地区组队、交易、PK。
✅ 统一运营:游戏版本、活动、数据全球同步,减少多服维护成本。
❌ 网络挑战:高延迟、跨地区通信、DDoS 攻击防护等问题更复杂。
❌ 运维难度:需要全球部署服务器节点,优化网络延迟,保证稳定性。
典型例子:
-
《原神》(部分模式采用全球服)
-
《魔兽世界》国际服
-
《PUBG Mobile》(部分版本)
全球服的架构如何设计?
全球服通常采用 "全球分布式 + 区域同步" 的架构,常见方案包括:
① 单一全球集群(Centralized Global Server)
-
所有玩家接入同一个全球服务器集群(如 AWS/GCP 全球机房)。
-
优点:数据完全一致,无需跨服同步。
-
缺点:延迟高(如亚洲玩家连接欧美服务器),不适合实时性强的游戏。
✅ 适用场景:回合制、策略类游戏(如《部落冲突》早期版本)。
② 区域集群 + 全局同步(Regional Clusters + Global Sync)
-
全球分为多个区域(如北美、欧洲、亚洲),每个区域有自己的服务器集群。
-
区域间通过专线/VPN 同步关键数据(如玩家排名、交易、跨服 PvP)。
-
优点:低延迟,适合实时性强的游戏。
-
缺点:跨区交互可能有延迟,需要额外同步机制。
✅ 适用场景:MMO、FPS、MOBA(如《英雄联盟》国际服)。
③ 边缘计算 + 全球负载均衡(Edge Computing + Global Load Balancing)
-
玩家连接最近的边缘节点(如 AWS Local Zone、Cloudflare),减少延迟。
-
核心逻辑(如战斗、交易)由中心服务器处理,边缘节点只负责渲染和输入转发。
-
优点:超低延迟,适合大世界游戏。
-
缺点:架构复杂,成本高。
✅ 适用场景:开放世界游戏(如《原神》部分模式)。
像原神那样的大世界地图,服务器架构如何设计?
分区服务器(Zone-Based Sharding)
-
大世界被划分为多个 "Zone(区域)",每个 Zone 由独立的服务器管理。
-
玩家进入不同 Zone 时,服务器动态切换(类似 MMO 的 "场景分服")。
帧同步 + 状态同步混合
-
PvE(副本、剧情):可能采用 状态同步,服务器计算关键逻辑(如 BOSS 血量)。
-
PvP(多人联机):可能采用 帧同步 或 优化状态同步,确保多人战斗同步。
13.6.3 热更新
游戏服务器如何做热更新?
热更新(Hot Update) 是指 在不重启游戏服务器的情况下,动态更新代码、配置或数据,使修改立即生效,从而避免停机维护,提升玩家体验。
-
脚本化(Lua/Python/JavaScript)
原理: 核心逻辑用 C++/Go/Java 实现,但 业务逻辑(如技能、任务、活动)用脚本语言(如 Lua)编写,热更新时只需替换脚本文件,无需重启服务。
优点:
✔ 灵活:脚本可以动态加载/卸载,修改后立即生效。
✔ 高性能:核心逻辑仍用 C++/Go,脚本只处理业务逻辑。
✔ 广泛使用:如《魔兽世界》《王者荣耀》部分逻辑用 Lua 编写。
缺点:
❌ 性能略低:脚本比原生代码慢(但游戏业务逻辑通常影响不大)。
❌ 调试复杂:脚本错误可能导致服务器崩溃(需沙盒隔离)。
代表技术:
-
Lua(最常用):通过
loadfile或luabridge动态加载脚本。
-
-
动态链接库(DLL/so 热替换)
原理: 将 核心逻辑编译成动态库(如 Windows 的
.dll或 Linux 的.so),运行时动态加载,热更新时替换 DLL/so 文件并重新加载。优点:
✔ 高性能:直接调用原生代码,无脚本性能损失。
✔ 适用于底层逻辑:如战斗计算、网络协议等。
缺点:
❌ 兼容性问题:新旧 DLL 接口必须兼容,否则可能崩溃。
❌ 平台依赖:Windows/Linux 需分别处理。
代表技术:
-
Windows(DLL 热替换):通过
LoadLibrary+FreeLibrary动态加载。 -
Linux(so 热替换):通过
dlopen+dlclose动态加载。
-
-
配置热更新(JSON/Protobuf/Redis)
原理: 游戏配置(如任务奖励、活动时间、数值表)存储在外部文件(JSON/Protobuf)或数据库(Redis/MySQL),热更新时只需修改配置并通知服务器重新加载。
优点:
✔ 最安全:不涉及代码变更,仅更新数据。
✔ 适用于非逻辑变更:如活动规则、商城商品、排行榜规则。
缺点:
❌ 仅适用于配置:不能修改核心逻辑。
代表技术:
-
JSON/CSV/Protobuf:存储任务、技能、活动配置。
-
Redis:实时更新配置,所有服务器节点共享最新数据。
-
-
代码热更新(JIT/AOT + 热部署)
原理: 某些语言(如 Java(HotSwap)、C#(Unity DOTS)、Go(插件))支持 运行时代码替换,但通常限制较多。

被折叠的 条评论
为什么被折叠?



