开发者笔记:游戏开发中抽象物品模块接口的设计
抽象物品的定义
“抽象物品”这一术语是为便于说明而创造的,它用于指代游戏系统中任何可附加到玩家身上的元素,例如金币、宝石、经验、道具、英雄、宠物、装备、积分、体力等。这些元素都可以通过通用的抽象物品接口来表示。实际上,几乎所有游戏项目都在使用抽象物品(至少我所接触过的项目都是如此),只是很少有人对其进行正式归纳。
数据结构
抽象物品的数据结构本质上是一个联合体(union),其中包含了各种可能的数值和数据。由于是联合体,所以其中只有一部分数据会起作用。以下是我们项目中使用 Protocol Buffer 定义的数据结构:
message CommonItem {
enum ItemType {
COIN = 0;
GEM = 1;
EXPERIENCE = 2;
ITEM = 3;
HERO = 4;
PET = 5;
EQUIPMENT = 6;
SCORE = 7;
ENERGY = 8;
}
ItemType type = 1;
optional int32 coin = 2;
optional int32 gem = 3;
optional int32 experience = 4;
optional int32 item_id = 5;
optional int32 hero_id = 6;
optional int32 pet_id = 7;
optional int32 equipment_id = 8;
optional int32 score = 9;
optional int32 energy = 10;
}
从上述代码可以看出,这个结构相对复杂。CommonItem
包含一个 type
字段,用于指明该抽象物品的具体类型,后面跟着各种具体物品携带的数据,这些数据可能是玩家的某个属性值,也可能是背包、英雄等相关信息。这里使用了 optional
选项。当然,抽象物品的数据也可以用其他方式表示,比如直接记录几个数字,第一个数字代表类型,后面数字的含义由类型决定;也可以使用带分隔符的字符串。从原理上来说,这些方式都是相似的,但使用 Protocol Buffer 进行结构化表示更加直观。
为什么要做抽象
尽管上述结构较为复杂,构建和解析也比较麻烦,但引入抽象物品仍然有重要意义,主要原因如下:
解耦各个模块
在游戏系统中,当一个模块调用另一个模块时,就会产生依赖关系,而我们需要尽量减少这种依赖。大多数情况下,模块间的调用都是在修改彼此的数据,更具体地说,大部分数据修改操作是在添加或消耗某些物品。例如,副本掉落道具会添加到背包,打副本会消耗体力,完成任务会添加经验和金币。
假设副本可以同时掉落金币、经验、体力和背包物品,那么副本模块就会同时依赖这些模块。这意味着,只要这些模块中的任何一个接口发生变化,我们就必须修改副本模块的代码。再看背包模块,副本、任务、宝箱、商店等模块都会对其数据进行改动,所以这些模块都依赖于背包模块。一旦背包模块的接口发生改变,所有依赖它的模块代码都需要修改。
显然,这是一种不合适的 M N 关系。为了解决这个问题,我们引入一个分发模块作为“中介”,抽象物品作为“载体”。所有调用模块只依赖分发模块,它们只需要知道要添加或消耗抽象物品,而无需关心具体维护对应数据的模块。分发模块负责将抽象物品分发到不同的数据模块,这样 M N 的关系就变成了 M + N。
配表更加灵活
以副本掉落为例。如果没有抽象物品,当策划希望副本掉落金币时,可能会在配置表中添加一列来定义金币。几天后想换成掉落经验,就需要通知程序修改代码;再过几天又想换成背包物品,就需要再添加几列并再次通知程序修改代码。更糟糕的是,如果后期新开放了积分系统,很多配置表都需要添加积分这一列。这种方式显然不够灵活。
如果设计了恰当的抽象物品,很多产出和消耗都可以配置成抽象物品。策划可以自由灵活地修改数值,而程序只需要编写一次解析抽象物品配置的代码。
方便交换数据
以抽卡功能为例,服务器需要将抽出的结果返回给客户端显示。此时,直接使用抽象物品这个数据结构来交换数据,在定义消息接口时无需关心具体产出物品的类型。另一方面,无论抽象物品实际是金币、背包物品还是能量,客户端通常都要以相似的形式在界面上显示,如一个常见的图标、图标下方的物品名字以及图标右下角的数量。有了抽象物品结构后,只需要正确处理好抽象物品的显示,所有系统的界面都可以直接调用,而无需关心具体要展现的物品是什么。
分发模块设计
分发模块在前面已经大致介绍过,可以简单理解为一个代理,其功能是将对抽象物品的操作映射到对实际数据的操作。
分发模块主要定义了以下三个接口:
Add(CommonItem, Context)
:添加一项抽象物品。Remove(CommonItem, Context)
:删除一项抽象物品。Has(CommonItem, Context)
:判断玩家是否拥有一份抽象物品。
需要注意的是,有些数据并非直接附加到玩家本身,例如英雄经验应附加到某个英雄上,技能经验应附加到某个技能上。因此,在添加抽象物品时,可能需要一些额外的上下文数据(如英雄 ID、技能 ID、宠物 ID 等),这些上下文数据可以从触发操作的环境中获取。例如,在打副本时,上下文数据应包含当前出战的英雄,副本模块需要将这些上下文数据告知分发模块,以便其正确分发。
然而,可能会出现这样的问题:如果数据配置存在问题,当要添加的抽象物品是英雄经验,但上下文数据中不包含英雄 ID(例如开宝箱触发的情况),就无法正确添加。坦率地说,目前还没有很好的解决办法,这也是使用抽象统一接口所带来的代价。