介绍
表达式系统对外表现为一种脚本语言,用户通过编辑一个脚本,调用程序中某些特定的函数,从而实现自己需要的效果。在游戏开发中这个系统通常由程序组开发维护,交由策划组使用,来丰富游戏内容,例如不同英雄的技能逻辑、不同NPC的交互逻辑等,这些工程交给程序来做未免过于精细且往往达不到策划想要的效果,并且这些细节逻辑的变动会牵扯到程序策划两个组的联调,影响工作效率,会徒增不少工作量。所以程序选择造个轮子然后把工作量丢给策划,引入表达式系统的好处在于,一方面我们可以让策划自行决定这个行为的具体逻辑,另一方面策划们可以将不同技能的具体逻辑保存到一张Excel表中,每一个技能是什么样的逻辑都可以一目了然。
表达式系统(Express System)是指一种用在大型游戏项目中的系统,这个系统提供了一种脚本语言,形式上非常像 Lua
,具体表现形式一般如下:
if Func(10) then
DoSth(EMsgId)
else
DoOtherThing(EMsgId)
endif
这个系统常用于给游戏策划编写相关逻辑,从而减轻程序员的维护难度,脚本本身仅支持简单的顺序和分支逻辑,不支持循环,但足够了。
表达式系统的组成较为简单,表达式系统由以下几部分组成:
程序运行时的上下文
(Context, ctx)
程序对外暴露的表达式接口
(AtomOperation, atom)
表达式脚本代码
(Expression, expr)
这个系统的诞生就是为了解决大型游戏中,大量角色技能的逻辑处理等相关问题,如果使用Lua
进行同样地操作,则可能遇到Lua解释器运行过慢的问题,而表达式则可以是基于C++构建的一整套系统,因此在运行上有更好的效率。
表达式脚本代码的组成
我们参照C++
和Lua
代码的结构来设计脚本的结构语法,最终的设计除endif
外,与Lua
完全一致代码整体由关键字,上下文对象,方法,公式四种组件组成,如下图所示:
关键字 Keywords
为了保证表达式系统的简洁与可读性,我们仅引入下面的几个关键字:if
then
else
endif
,他们分别用于表示分支逻辑,分支条件,分支结束。语法结构中不提供循环,如果需要,则由程序暴露为一个方法供策划调用,而不直接由策划编写。
上下文对象 Context Objects
上下文对象是指表达式执行时由程序提供的核心环境变量,指向当前操作涉及的游戏对象或其他可能需要策划处理的内容。这里要强调的是:表达式执行的时机事实上是由程序员决定的,而策划决定的只有表达式的内容。因此程序需要将这些对象以关键字的形式暴露给策划以供调用,并在执行表达式前,明确表达式的上下文。
以战斗中技能释放的逻辑为例,策划只需要设计攻击方 Src
与受击方 Tgt
的相关行为,而不关注攻击方 Src
与受击方 Tgt
具体是什么,因此程序中需要指定攻击方 Src
与受击方 Tgt
分别是什么对象,并通过上下文对象暴露出来,以供调用,例如 Src
和 Tgt
的指针具体指向什么对象,并在执行脚本的函数中将他们以上下文的形式传入,作为脚本执行时的环境变量。
上面的图中使用到Src
来表示这个方法操作的对象是动作的发起者,Tgt
则可能是NPC乘坐的载具,这句话可能的意思为“执行表达式的NPC将用100/载具速度的时间到达超市”。
公式 Formula
公式是表达式系统中进行数值计算和逻辑判断的核心单元。它由属性和常量通过运算符组合而成,最终计算出一个具体的值(数字、布尔值等)。策划无需关心底层实现,只需像写数学算式一样组合这些元素。前面的例子中提到的10
就是最简单的一个公式。
我们再来举一个例子,我们需要将对方的钱转一半给自己,那我们可以这么写:
IncMoney(Src, TgtMoney / 2) # Src 增加 (TgtMoney / 2) 的值
DecMoney(Tgt, TgtMoney / 2) # Tgt 减少 (TgtMoney / 2) 的值
在这里, TgtMoney / 2
是一个公式。其中 TgtMoney
是属性,2
是常量,/
是运算符。我们假设 TgtMoney
当前是 200
,则公式计算结果为 100
。那么这里的方法调用等同于 IncMoney(Src, 100)
和 DecMoney(Tgt, 100)
。
由此可见,一个公式就是一段计算表达式,当脚本执行到包含公式的地方时,系统会立即计算这个公式的结果。
公式的类型
公式得到的值有两种类型,一种是数,一种是布尔值。
为了程序实现更方便,也为了让策划更易懂,我们在这里以C/C++风格定义:true
内部视为1
,false
内部视为0
,0
视为 false
,所有非 0
数都视为 true
,这样就保证了 bool
和 int
的隐式转换。在策划使用的时候,他们可以直接用 1
和 0
表示真假。
属性 Properties
属性是公式的一个核心组成部分,对于脚本的用户,也就是策划来说,它相当于一个数,但对于程序来说,它本质上是一个除程序上下文外,没有其他参数的一个有返回值的函数。例如前面提到的TgtMoney
实际上对应的是 int GetTgtMoney(EXPR_CTX *ctx)
这样的一个函数,函数返回值即为TgtMoney
的值。
属性不同于程序变量,它是不可变的(即不能使用 TgtMoney = 10
这样的写法),如果想要修改需要通过 DecMoney
等方法实现,因此策划看到的所有“名字”都是只读的、源自游戏对象状态的值,而非可写的存储单元。
常量 Literals
常量是公式中另一个提供数据的组分,说白了就是直接写在脚本中的一个具体的数,例如前面的 10
和 2
。
运算符 Operators
公式中用于操作数据的组分,用于对属性和常量进行计算或比较,不必多说,一笔带过。
用于计算的符号 (+
, -
, *
, /
, %
),比较的符号 (==
, !=
, >
, <
, >=
, <=
),逻辑组合的符号 (&&
, ||
, !
),以及括号 ()
。
方法 Methods
表达式系统中可执行的操作指令,用于触发游戏逻辑,在程序视角中,一个脚本方法实际对应一个C++函数,或其他语言的一个类的成员方法,方法的具体实现由程序员决定,策划只需要使用简单命名就可以调用,以执行复杂逻辑。另外,方法支持传参,并且带有返回值,可以用于公式计算和逻辑判断。
表达式在程序中的用法
前面我们讲到,程序员决定脚本的运行时机,而策划决定脚本的具体内容,程序为策划的脚本提供上下文支持。
程序怎么决定脚本的运行时机呢?说白了就是调用函数呗。我们假设一个场景:
玩家使用英雄A释放技能A-1。
A-1的描述为:对敌方英雄造成300点伤害,如果敌方被该技能击杀,则自己获得
对方等级*1.2
块钱那我们可以很轻松的得到A-1技能的表达式脚本为:
Attack(Tgt, 300) if !TgtIsAlive then IncMoney(Src, TgtLevel * 1.2) endif
在程序的视角中,我们可能会有这样的一段代码:
void Actor::UseSkill(int skill_idx, ACTOR &target) {
Skill &skill = GetSkillByIdx(skillIdx);
SkillExprCtx ctx = SkillExprCtx(*this, target); // 准备脚本执行所需的上下文,在上下文中指定Src和Tgt
int result = skill.SkillLogic().Exec(ctx);
if (result != 0) {
printf("[ERROR] Skill %s executed with result: %d\n", skill.name().c_str(), result);
return;
}
printf("[INFO] Skill %s executed successfully.\n", skill.name().c_str());
}
代码当中 .Exec(ctx)
即为脚本执行所用的函数,函数以上下文为参数,脚本退出的结果为result。
在这样一套系统中,程序只需要关注技能的释放时机,并为脚本提供上下文,而不用关注技能具体的逻辑,策划撰写的逻辑将会分装在一个EXPR对象实例中,程序通过调用Expr::Exec()
函数,并传入上下文即可。
下面是上述使用中与玩家和技能的相关辅助定义,可略过不看。
玩家相关定义
// 游戏内所有实体Entity class Entity {}; // 玩家实体Actor class Actor : public Entity { public: void UseSkill(int skillIdx, Actor &target); Skill &get_skill_by_idx(int skillIdx); private: Skill _skills[MAX_SKILL_SIZE]; };
技能相关定义
// 技能 - 提供技能名字和表达式形式的技能逻辑 class SKILL { public: std::string &name() { return _name; } Expr &skill_logic() { return _skill_logic; } private: std::string _name; Expr _skill_logic; };
表达式系统在程序中的结构
// 表达式类,核心是exec函数
class Expr {
public:
int exec(EXPR_CTX &ctx);
};
// 表达式上下文基类
class ExprCtx {};
// 专用于技能表达式的上下文
class SkillExprCtx : public ExprCtx {
public:
SkillExprCtx(Entity &source, Entity &target) : _source(source), _target(target) {}
Entity &source() { return _source; }
Entity &target() { return _target; }
private:
Entity _source;
Entity _target;
};
上面定义了三个类,分为Expr
和ExprCtx
两个,策划的表达式脚本封装到Expr
中,在执行时传入ExprCtx
来提供上下文环境变量