介绍

表达式系统对外表现为一种脚本语言,用户通过编辑一个脚本,调用程序中某些特定的函数,从而实现自己需要的效果。在游戏开发中这个系统通常由程序组开发维护,交由策划组使用,来丰富游戏内容,例如不同英雄的技能逻辑、不同NPC的交互逻辑等,这些工程交给程序来做未免过于精细且往往达不到策划想要的效果,并且这些细节逻辑的变动会牵扯到程序策划两个组的联调,影响工作效率,会徒增不少工作量。所以程序选择造个轮子然后把工作量丢给策划,引入表达式系统的好处在于,一方面我们可以让策划自行决定这个行为的具体逻辑,另一方面策划们可以将不同技能的具体逻辑保存到一张Excel表中,每一个技能是什么样的逻辑都可以一目了然。

表达式系统(Express System)是指一种用在大型游戏项目中的系统,这个系统提供了一种脚本语言,形式上非常像 Lua,具体表现形式一般如下:

if Func(10) then
    DoSth(EMsgId)
else
    DoOtherThing(EMsgId)
endif

这个系统常用于给游戏策划编写相关逻辑,从而减轻程序员的维护难度,脚本本身仅支持简单的顺序和分支逻辑,不支持循环,但足够了。

表达式系统的组成较为简单,表达式系统由以下几部分组成:

  1. 程序运行时的上下文 (Context, ctx)

  2. 程序对外暴露的表达式接口 (AtomOperation, atom)

  3. 表达式脚本代码 (Expression, expr)

这个系统的诞生就是为了解决大型游戏中,大量角色技能的逻辑处理等相关问题,如果使用Lua进行同样地操作,则可能遇到Lua解释器运行过慢的问题,而表达式则可以是基于C++构建的一整套系统,因此在运行上有更好的效率。

表达式脚本代码的组成

我们参照C++Lua 代码的结构来设计脚本的结构语法,最终的设计除endif外,与Lua完全一致代码整体由关键字,上下文对象,方法,公式四种组件组成,如下图所示:

关键字 Keywords

为了保证表达式系统的简洁与可读性,我们仅引入下面的几个关键字:if then else endif ,他们分别用于表示分支逻辑,分支条件,分支结束。语法结构中不提供循环,如果需要,则由程序暴露为一个方法供策划调用,而不直接由策划编写。

上下文对象 Context Objects

上下文对象是指表达式执行时由程序提供的核心环境变量,指向当前操作涉及的游戏对象或其他可能需要策划处理的内容。这里要强调的是:表达式执行的时机事实上是由程序员决定的,而策划决定的只有表达式的内容。因此程序需要将这些对象以关键字的形式暴露给策划以供调用,并在执行表达式前,明确表达式的上下文。

以战斗中技能释放的逻辑为例,策划只需要设计攻击方 Src 与受击方 Tgt 的相关行为,而不关注攻击方 Src 与受击方 Tgt具体是什么,因此程序中需要指定攻击方 Src 与受击方 Tgt 分别是什么对象,并通过上下文对象暴露出来,以供调用,例如 SrcTgt 的指针具体指向什么对象,并在执行脚本的函数中将他们以上下文的形式传入,作为脚本执行时的环境变量。

上面的图中使用到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 内部视为1false 内部视为00 视为 false ,所有非 0 数都视为 true ,这样就保证了 boolint的隐式转换。在策划使用的时候,他们可以直接用 1 0 表示真假。

属性 Properties

属性是公式的一个核心组成部分,对于脚本的用户,也就是策划来说,它相当于一个数,但对于程序来说,它本质上是一个除程序上下文外,没有其他参数的一个有返回值的函数。例如前面提到的TgtMoney 实际上对应的是 int GetTgtMoney(EXPR_CTX *ctx) 这样的一个函数,函数返回值即为TgtMoney 的值。

属性不同于程序变量,它是不可变的(即不能使用 TgtMoney = 10 这样的写法),如果想要修改需要通过 DecMoney等方法实现,因此策划看到的所有“名字”都是​​只读的、源自游戏对象状态的值​​,而非可写的存储单元。

常量 Literals

常量是公式中另一个提供数据的组分,说白了就是直接写在脚本中的一个具体的数,例如前面的 102

运算符 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;
};

上面定义了三个类,分为ExprExprCtx两个,策划的表达式脚本封装到Expr中,在执行时传入ExprCtx来提供上下文环境变量

七岁打码天才,游戏开发糕手