🐉游戏开发日志🦄

🦁🫎 记录游戏开发过程中那些有意思的事情!🦌🐙

一种基于DSL的数据驱动式大规模游戏战斗技能的实现方法

背景 我曾经为两款游戏开发战斗技能系统,使用的是传统OOP的方式,大概设计是有个基类BaseAbility,里面有一些基础的通用逻辑,然后不同的技能实现在不同的子类中。2020年我们立了一个新项目,策划告诉我这个项目以英雄战斗技能为主,可能多达500个技能。 过去两个项目中基于OOP的战斗系统已经让我陷入纠结和痛苦之中,因为一直在为类继承体系而纠结,虽然继承可以提高复用性,但也大大增加耦合性和复杂性,到了后期子类爆炸,逻辑在不同类中来回穿梭,属性计算也在庞大的类继承体系中迷失。 为了让战斗技能系统变的简单清晰,这个项目我准备用一种全新的基于DSL的数据驱动式的战斗技能系统,战斗框架采用ECS架构。数据驱动具体来讲是基于DSL来表述一个技能的全部逻辑,DSL在维基百科中的定义: 领域特定语言(英语:Domain-specific language,缩写:DSL),也称为特定域语言,是专门针对特定应用领域的计算机语言,和可以用在多种领域的通用语言(GPL)恰好相反。像HTML专门用在网页设计上,就属于领域特定语言。 战斗系统代码负责解析执行DSL中的逻辑,ECS是为了更好的实现逻辑和表现层分离,因为我们战斗系统是同一套代码同时运行在客户端和服务端,至于ECS带来的性能上的收益则是额外收获。 技能DSL设计 在参考了Dota2的技能描述语言后,我决定设计一套更简洁,功能更丰富的DSL来表达技能的配置系统。这套DSL必须足够简单,策划容易阅读和编写,不需要太多专业编程知识。经过研究,我发现yaml格式就可以很好的作为一种DSL的载体来定义技能。最终用这套系统实现了700多个技能,开发技能效率很高,上线后效果也不错。 后期基本是策划在做技能,程序偶尔辅助下。 先看个近战普攻技能示例: # MeleeBasicAttack.yaml # 近战普通攻击技能模板 # 技能每个等级的伤害数值 DamageTable: Ability: Type: PHYSICAL Levels: - 140% * $ATK + 600 - 280% * $ATK + 6000 - 320% * $ATK + 21000 - 400% * $ATK + 30000 # 技能主动作序列 MainActions: - PlayBasicAttackAnim: Sequence: - Attack1: 3 - Attack2: 1 - WaitCastPoint: Duration: $CAST_POINT - PlaySound: Name: $CAST_SOUND - PlayVfx: Target: $TARGET Prefab: Battle/Battlefield/VFX_General_AttackHit - AbilityApplyDamage: Target: $TARGET - PlaySound: Target: $TARGET RandomRange: - SFX_HIT_TARGET_1 - SFX_HIT_TARGET_2 - WaitRecoverTime: Duration: $RECOVER_TIME 第一段DamageTable用来定义技能的伤害,因为我们游戏技能可以升级所以写了4个等级的伤害,每一行都是一个伤害计算公式,公式里有常量和变量引用,上面技能DSL里$ATK用来引用英雄当前的物理攻击力,技能执行Runtime会动态替换里面的变量,上面技能1级伤害是140%的英雄物理攻击 + 600点伤害,后几级以此类推。DamageTable是一个Map结构,技能伤害的Key是Ability,技能可以有多种伤害,在DamageTable定义多个伤害配置。 ...

July 25, 2023 · 2 min · Jee

简单谈谈战斗系统中属性计算

英雄属性 单位状态(State) 单位状态(晕眩/冰冻/沉默/魅惑/嘲讽/石化/禁锢/隐身/无敌等),只需要一个bool标记,多种状态可以并存,因此我们用一个64位整数来存储,每个bit代表一种状态,最多可以有64种状态,足够游戏中战斗用了。 public enum UnitStateType { Stunned = 0; // 晕眩 Sleeping = 1; // 沉睡 Fronzen = 2; // 冰冻 Chaos = 3; // 混乱 Silence = 4; // 沉默 Disarm = 5; // 缴械 Taunted = 6; // 嘲讽 Stone = 7; // 石化 Rooted = 8; // 禁锢 Invisible = 9; // 隐藏 Invincible = 10; // 无敌 // 更多状态定义在下面,最多大值63 } public class UnitStates { public UInt64 Data { get; private set; } public void AddState(UnitStateType type) { Data |= GetStateBits(type); } public void RemoveState(UnitStateType type) { Data &= ~GetStateBits(type); } public bool HasState(UnitStateType type) => (Data & GetStateBits(type)) != 0; public UInt64 GetStateBits(UnitStateType type) => (1 << (int)type); } 单位属性(Attribute) 为了让战斗属性更加灵活,我们并不在一个类里定义所有属性,而是用表格的方式存储所有属性列表,方便以后增加新的属性。我们使用定点数来确保战斗计算结果的确定性,所以数值存储使用FixedNumber类。 public enum UnitAttributeType { Health, MaxHealth, PhysicalDamage, MagicalDamage, PhysicalArmor, MagicalArmor, CriticalChance, CriticalDamage, MoveSpeed, AttackSpeed, AttackRange, // 更多属性添加下面,数字递增 MaxCount } public class UnitAttributes { public List<FixedNumber> Data { get; private set; } public UnitAttributes() { Data = new List<FixedNumber>(); for (int i = 0; i < UnitAttributeType.MaxCount; i++) { Data.Add(0); } } public void SetAttribute(UnitAttributeType type, FixedNumber amount) { Data[(int)type].Set(amount); } public void AddAttribute(UnitAttributeType type, FixedNumber amount) { Data[(int)type].Add(amount); } } 战斗属性计算 属性的改变一般有两种:绝对值加量,百分比加量。我们的游戏中还有一种特殊的技能效果:属性拷贝,即一个英雄偷取另外一个英雄属性。由于属性计算过程比较复杂,被各种Buff/Debuff影响,之前我做的一个战斗系统中,偶尔会出现策划反应数值不大对劲,但又没有精确的证据证明数值有不错错误。 为了解决这个问题,新的战斗属性计算我采用这样的设计:所有属性的改变都单独存储,每帧都重新计算最终属性。 ...

May 11, 2023 · 2 min · Jee

游戏服务端双缓冲队列数据落地

本文介绍一种游戏服务端利用双缓冲队列数据落地的技术,这种技术既能保证高响应速度又能降低数据丢失的风险。 首先介绍了游戏服务端数据模型和数据生命周期,然后介绍双缓冲队列的具体设计。 数据模型 玩家数据的落地存储使用的是MySQL,但不适合用关系模型来构建玩家数据。 因为玩家数据是一种树形结构,关系模型是一种二维结构。 Player = { id = 123456, name = "Winston", level = 1, gold = 2000, gem = 300, vip = 4, items = { {id=101, count=20}, {id=102, count=30}, }, heroes = { {id=1001, level=1}, {id=1002, level=2}, }, quests = { {id=10001, condition={type=1, number=10}}, {id=10002, condition={type=2, number=20}}, }, ... } 以上代码是我们游戏服务端玩家数据的部分片段。 可以看出id,name之类的字段对应MySQL中的一列还说的过去,但像items,heroes这类复合数据结构如果非要用关系模型建模 的话可能需要几十张表。 我们用一张MySQL表来存储玩家数据,表定义如下: CREATE TABLE players ( id INT NOT NULL, name VARCHAR(64) NOT NULL, level INT NOT NULL, gold BIGINT NOT NULL, gem BIGINT NOT NULL, vip INT NOT NULL, items TEXT NOT NULL, heroes TEXT NOT NULL, quests TEXT NOT NULL, ... PRIMARY KEY (id) ); Player树形结构中的第一层对应MySQL的一列,基本数据类型可以跟MySQL列数据类型一一对应,复合类型用TEXT或BLOB存储。 ...

December 1, 2022 · 2 min · Jee

一种让LuaTable变成只读的方案

免费赠送一个让Lua Table变成只读的代码,如果使用Lua作为配置表,有必要让table变成只读的,防止运行时被误改。此题也是我对有Lua使用经验者的应试者常用的面试题:) local M = {} local function readOnlyTableIter_pairs(t, k) local v k, v = next(t.__readonly, k) if v ~= nil then return k, v end end local function readOnlyTableIter_ipairs(t, i) i = i + 1 local v = t.__readonly[i] if v ~= nil then return i, v end end function M.MakeReadOnly(t) assert(type(t) == "table", "except table at parameter 1") assert(getmetatable(t) == nil, "table already has metatable") for k, v in pairs(t) do if type(v) == "table" then t[k] = M.MakeReadOnly(v, includeChildren) end end return setmetatable({__readonly=t}, { __index = t, __newindex = function() error("Can't modify read-only table") end, __pairs = function(_t) return readOnlyTableIter_pairs, _t, nil end, __ipairs = function(_t) return readOnlyTableIter_ipairs, _t, 0 end, __len = function(_t) return #_t.__readonly end, __metatable = false, }) end return M

April 23, 2022 · 1 min · Jee

C++的右值引用

右值引用主要用于移动语义(Move semantics)和完美转发(Perfect forwarding) Move semantics Move semantics能转移一个对象到另一个对象上,避免不必要的内存拷贝,被拷贝对象不能在其它地方被引用。要实现Move semantics只要给类定义移动构造函数和可选的移动赋值操作: class Memory { public: Memory(Memory&& other) noexcept { _data = other._data; _length = other._length; other._data = nullptr; other._length = 0; } Memory& operator=(Memory&& other) noexcept { if (this != &other) { delete[] _data; _data = other._data; _length = other._length; other._data = nullptr; other._length = 0; } return *this; } private: void *_data{nullptr}; int _length{0}; } Perfect forwarding Perfect forwarding主要为了减少重载函数,主要是泛型函数用引用类型作为参数。 struct W { W(int&, int&) {} }; struct Z { Z(const int&, const int&) {} }; template <typename T, typename A1, typename A2> T* factory(A1& a1, A2& a2) { return new T(a1, a2); } int a = 4, b = 5; W* pw = factory<W>(a, b); // OK Z* pz = factory<Z>(2, 2); // Valid cause 2 is right value 把上面的泛型函数参数改成右值引用就能解决这个问题: template <typename T, typename A1, typename A2> T* factory(A1&& a1, A2&& a2) { return new T(std::forward<A1>(a1), std::forward<A2>(a2)); } Z* pz = factory<Z>(2, 2); // OK References: https://learn.microsoft.com/en-us/cpp/cpp/rvalue-reference-declarator-amp-amp?view=msvc-170

January 16, 2022 · 1 min · Jee

游戏公司不用微服务吗

知乎上有一个问题:“游戏公司为什么不用微服务”。我的观点是微服务不是一个技术问题,而是一个管理问题。 我大学的专业是软件工程,但说实话上完大学也没有吃透软件工程。一方面原因是学生没做过大型商业项目,对大项目里的问题没有体会。另一方面老师基本也是对着软件工程的教材备课,然后讲解给我们,他本人也未必做过大型项目,多数是读到博士然后直接去高校教书。 现在我已经工作了超过十年,参与开发了十几个项目,并作为负责人领导了七个大大小小的项目。然后体会是大项目确实开发效率低,原因有两个: (1)代码复杂度 一般项目开始时只有几个人,甚至只有一个人。这时代码的复杂度并不高,每个人基本都能理解整个代码库。随时进度的推进会加入越来越多的开发者,这时一个人想理解整个代码库已是不可能。这个问题不能归咎于程序员的技术水平低和管理不善,根本原因是代码规模的增长必然使得代码和团队变的笨重。 (2)团队原因 随着团队成员的增加,交流成本开始指数式上升。如果整个团队有 n 个程序员,为了了解其他人的工作,你需要跟 n - 1 个人逐一交流(口头或者书面),那么整个团队的交流路径总数就是 n * (n - 1) / 2。这意味着,交流成本的增长速度是人员增长速度的平方,团队人数越多,协同的难度就越大。 大团队保持扁平化管理,也会越来越困难,必须拆分成较小的群体。这时,对等的交流会被自上而下的交流所取代。团队成员会感觉,自己从平等的利益相关者,转变为普通的工作人员,工作动机受到了影响,责任感和主人翁意识都会淡漠。 解决方法 (1)代码解耦 代码解耦可以分成两个方向,我称它纵横分治: 纵向分治:整个软件分成若干层,越底层处理的问题越接近计算机,越上层处理的问题越接近用户。上层只能访问下层,严禁下层访问上层。 横向分治:每一层内也分成若干模块,模块对外只提供必要的公开接口,严禁一个模块访问另一个模块的私有接口。这在C++/C#/Java这类语言中用private很容易做到,脚本语言的话只能靠代码规范文档来指导。 (2)团队解耦 要把大团队成员分成若干的小团队,每个小团队只做一个项目的子集,相当于把大项目拆成多个小项目。那这些小项目之间如何通信呢?如果是单进程软件项目,例如Photoshop这类软件,还是通过提供API接口的形式供外部调用。如果是互联网项目,每个小项目都是一个或多个独立的进程,那么最好的方式是通过网络以某个标准协议进行通讯,例如REST。这就是微服务! 所以我认为微服务是一个解决团队耦合的比较理想的方案,团队解耦了那么代码也就解耦了,甚至代码都是物理级别的解耦,每个小团队都在各自的GIT仓库里工作。 游戏公司是否用微服务跟团队规模有关,如果是一只不超过100人的团队,没必要把团队分太细,而一般游戏开发团队大多几十人,上百人的已经是超大团队了,但跟互联网动辄几千人的团队规模还是没法比的。另外互联网应用主要逻辑都在服务端,比较容易微服务化,但游戏研发团队有一大半都在客户端,很难用上微服务。 参考 (1) http://www.ruanyifeng.com/blog/2021/05/scaling-problem.html

October 26, 2021 · 1 min · Jee

C++跨平台项目开发

简介:记录开发一个跨平台C++项目的历程 1. 背景 我司服务端项目大部分使用一个简单的模式:C++做底层负责网络/内存/多线程/日志等,内嵌一个Lua虚拟机,业务逻辑都用Lua写。这个模式既能提供高性能,又能让业务逻辑开发简单,安全。多年来运作良好。 过去几年,服务端的开发和运行环境均在Linux下。今年要开一个新项目,我心血来潮想要在Windows下开发,当然线上还是部署在Linux中。所以就需要让这个项目同时支持Linux和Windows。业务层面好说,因为用Lua开发,本身100%跨平台。所以只需要让C++代码能跨平台编译运行即可。 2. C++跨平台编译运行 我们的C++项目主要依赖STL/Lua/Socket库。 STL本身就是跨平台的,只要有C++编译器即可。线程库用的c++11的std::thread。Easy! Lua源代码是用标准C89写,基本零依赖,几乎所有平台都可以编译,但我懒的自己编译了,所以直接用社区提供的LuaBinaries。 Socket库我们是自己开发的,所以简单封装了一个Socket类,底层根据不同平台调用不同的API,对外提供统一的抽象接口。 遇到的第一个问题是:如何"一键"在Win/Linux下编译 Linux平台下,我们一直用Makefile,所以轻车熟路的写了一个,搞定Linux下编译。 Windows平台下,就没那么简单了,绕了点弯路。由于我本身用了多年的cygwin,所以直接用cygwin下的gcc编译,还能直接用我为Linux平台写的Makefile。这一步也很简单,但问题出在Lua的C模块上,我们用了一些开源的Lua C模块,例如lua-cjson,这个东西虽然也可以在cygwin下编译,但运行时Lua虚拟机总是不能正确加载,折腾了好久最终放弃。因为考虑到以后可能还会用到其他开源的项目,而且其他项目成员也不一定都喜欢装一个cygwin,所以我还是决定弄成Windows原生的exe和dll。 安装了一个VS2019(这东西真大,十几个G),所有C++代码丢进去,配置下链接上面提到的LuaBinaries,运行完美。那开源的项目如何编译成原生dll呢?由于开源项目大多提供了Makefile或CMake的编译脚本,很少直接提供VS工程的,所以我决定用mingw。下面以编译lua-cjson为例记录下过程。 安装MingW,并将MingW的bin目录加入Path中 下载lua-cjson-2.1.0(https://www.kyne.com.au/\~mark/software/download/lua-cjson-2.1.0.tar.gz) 修改lua-cjson的Makefile,放开## Windows (MinGW)下面的注释 ## Windows (MinGW) CC = gcc # 增加此行 TARGET = cjson.dll PREFIX = c:/LuaBinaries # 改成你的LuaBinaries目录 CJSON_CFLAGS = -DDISABLE_INVALID_NUMBERS CJSON_LDFLAGS = -shared -L$(PREFIX) -llua53 # 这里根据你的Lua版本配置 然后打开CMD,cd到lua-cjson-2.1.0目录,运行: mingw32-make 踩到过的坑:链接lua dll时gcc报错"lua53.dll 无法识别的格式"。卡了半天才发现原来mingw的32位和64位是两个独立的项目。我用google搜mingw的官网,找到了(https://sourceforge.net/projects/mingw/),这个是32位的。而我下载的LuaBinaries是64位,自然链接不到。64位的mingw是这个(https://sourceforge.net/projects/mingw-w64/),注意根据你的平台选择正确的版本!

May 22, 2021 · 1 min · Jee

记录一次C++游戏服务器项目开发

我们启动了一个新的游戏项目,产品上线后虽并未取得市场上的成功,但研发过程却很有价值,让我受益匪浅,值得记录下来。在这个项目以前我一直从事游戏客户端的开发工作,端游手游都做过。这个新项目我将负责游戏服务端的开发,并且大部分工作都是我一个人在做,从零构建了服务端的绝大部工作。 1. 架构 游戏采用的是世界同服设计,即所有玩家都同一个服里面玩,不需要选择服务器。这种类型的游戏服务端必须采用可横向扩展的架构,否则客户端激增的情况下会击垮某个服务,只能选择停服维护。我们已经看到很多产品在上线当天服务端崩溃停服了数个小时才恢复。 我将整个系统分成四种服务: 网关服 游戏逻辑服 聊天服(ChatServer) 存储服(DatabaseServer) 每一个服务都是可以动态扩容的,通过在不同的物理机上启动多个实例来分担请求的压力,网关通过负载均衡将客户端路由到后端服务池中。 2. 网关服(GateServer) GateServer 作为客户端与服务端通信的第一道关卡。客户端启动后,会先发送一个 HTTP 请求到 GateServer 上来获取一些客户端需要知道的信息,包含: 服务端状态 (正常/维护/关闭等状态) 客户端是否需要更新 (如需要的话会包含商店跳转地址或热更新文件下载地址) 公告 (进入游戏场景前显示的公告) GameServer和ChatServer的IP地址 (用某种负载均衡算法从池中选择一个) Gateway对内监控着GameServer集群的健康状态,对外提供http服务跟客户端通信,所以我使用nginx来实现。但因为需要能运行一些业务逻辑会涉及部分编程,所以我采用OpenResty。OpenResty在nginx里内嵌了Lua解释器,可以很方便的提供动态服务而不需要在后面在启动一个http server。Gateway启动时从配置文件里读取GameServer集群的IP地址列表,然后为每个IP都启动一个timer,这个timer里定期发送个TCP请求去查询GameServer的状态。因为每个timer都是运行在一个coroutine里的,而tcp通讯都是非阻塞,所以整个服务运行性能良好。 Gateway提供了一些管理接口给内部使用,比如添加删除GameServer。所以整个服务不需要暂停就能动态扩展服务器的容量。这些接口都是简单http请求,所以十分的方便。 Gateway还负责了公告功能,主要用来通知玩家一个主要信息,比如停服维护。 OpenResty实现这些功能太方便了,而且IO通信都是非阻塞的,经过Lua的coroutine封装后对使用者提供同步的API。以后的游戏甚至可以考虑完全用其作为GameServer。 3. 游戏逻辑服(GameServer) GameServer处理主要的业务逻辑,考虑到服务器端有发送信息给客户端的需求,并且我希望通信能更实时一些,所以使用一条TCP长连接跟客户端通信。GameServer有多个实例,每个都是等价的,玩家连接到那一个上都能进行游戏,所以可能很方便的横向扩展来满足不同的用户容量。 3.1 编程语言选择 我本身是一名C++客户端程序员,个人开发的第一款游戏也是采用cocos2dx/C++开发。第二款游戏考虑到cocos2dx开发效率不高,所以选择用Unity/C#开发。从C++转到C#是很自然很舒服的,毕竟两者比较类似。第三款游戏我开始负责服务器端开发,在编程语言的选择上纠结了很久。考察过Python/Erlang/Java,Erlang的Actor模型是开发高并发实时系统的最佳选择,但其古怪的语法和编程思维的巨大转变让我望而却步。Python加gevent组合似乎也是不错的选择,但作为一名C++程序员我对性能和底层有着特殊的偏好。Java是很多手游公司服务器端编程语言的选择,包括COC的服务器端也是用Java开发的,其实我对Java是很熟悉,实习期间就在一家做Java开发的公司干了半年,但它无论如何也不吸引了我。最终还是选择用我最熟悉的C++来开发: 多年的C++经验让我有信心写出高质量的C++代码 大部分游戏程序员都是写C++的,将来招人会更容易些。 在网络和内存部分C++可以实现的更高效 其他语言平台并没有优秀到让我毫不犹豫的放弃C++ 并且我还选择使用C++11,std::bind和std::function以及对lambda的支持,可以很方便的开发异步程序。std::thread和std::atomic也都很好用,其他的一些小的特性也提高了开发效率,让代码更干净。 3.2 EventLoop 我希望服务器可以单机承载10000的并发用户。使用异步IO是不二之选,而且Linux平台的epoll提供了性能优良的IO多路复用机制。而外我希望在处理网络数据时内存使用必须高效,环形缓存冲可以很好的解决这个问题。最终用了1800多行代码实现这个网络模块,性能上我很满意,而且代码量小,易于维护。 为什么不使用libevent? 一是因为libevent实现的太复杂,源码有20多万行。而是因为我对evbuffer的设计很不满意,处理网络数据最好用连续内存,而evbuffer内部使用链表将内存块串起来,使得我不得不多一次内存拷贝。三是因为写一个异步IO库并不需要太多工作量,我1800行代码就搞定了,自己维护起来方便。 目前游戏已经上线半年,玩家登录了几百万次,该模块运行稳定。以后整理下代码可以考虑开源出来,完全可以取代libevent, 3.3 多线程 为了充分利用多核,我使用了多线程,并且每个线程都跑了一个EventLoop。在主线程里监听一个端口,接到用户连接后把fd传给worker线程,默认情况启动四个worker线程,这个可以根据CPU数配置。以后的网络通信都发生在worker线程,业务逻辑也跑在worker线程。另外还启动若干Job线程,用来运行一些不需要那么及时又比较耗时的操作,比如更新排行榜。 3.4 通信协议 本来想用protobuf做通信协议,结果protobuf的C#实现都不能运行在Unity中,主要是因为Unity在移动平台上使用AOT使得protobuf的C#库无法运行。也找不到其他让我满意的,于是决定自己写。我希望协议定义在一个DSL中,然后生成服务器端和客户端都能用的代码,并且不使用反射,以便能够在移动平台上使用。考虑到解析一个自定义的DSL比较麻烦,我决定使用xml来定义协议。客户端和服务器的通信大部分都是Request-Response模式,所以一个协议定义大致如下: <protocols version="1"> <status> <code name="Ok" value="0"/> <code name="PasswordError" value="1"/> </status> <protocol name="Login" id="1"> <request> <field name="account" type="string"/> <field name="password" type="string"/> </request> <response> <field name="error" type="int"/> <field name="playerData" type="binary"/> </response> </protocol> </protocols> 二进制编码使用的是messagepack,这个协议工具虽然没有protobuf强大,但完成了我们需要的功能,而且在内存使用上更加高效。最主要的是生成的代码使用起来很舒服。生成工具用paython写的,可以生成C++,C#,Go代码。 ...

February 14, 2015 · 1 min · Jee