ET 框架 ConditionExpr 条件表达式解析器实战:从 Luban 配置到行为树运行
【免费下载链接】ETUnity3D Client And C# Server Framework项目地址: https://gitcode.com/GitHub_Trending/et/ET
导读
ConditionExpr(cn.etetet.conditionexpr)是 ET 框架中把 Excel/Luban 表格里填写的条件表达式字符串自动编译为行为树(Behavior Tree)节点的解析器与运行器,专门解决"进入副本条件、领取奖励条件、任务接取条件、功能开启条件"这类策划可配置的条件判断需求。读完本文你将掌握:条件表达式的完整语法(比较运算、逻辑运算、错误码绑定、多 owner 变量与专用节点参数)、它在 Luban bean 中的配置方式、底层"词法分析 → 语法分析 → 行为树生成"的编译链路,以及运行期通过BTEnv执行并返回错误码的调用范式。
一、包结构与定位
cn.etetet.conditionexpr位于 Packages/cn.etetet.conditionexpr,只依赖已声明的三个底层包:behaviortree、unit、numeric。它不重复造轮子,通用逻辑节点(&&/||/!)直接复用cn.etetet.behaviortree中的BTSequence、BTSelector、BTNot,自己只定义条件叶子节点。
目录约定:
Scripts/Model/Share 表达式模型、编译器(Lexer/Parser)、BT 节点数据、包元数据 Scripts/Hotfix/Share 运行时 Handler(BTNumericCompareHandler、ConditionRootHandler) Scripts/Hotfix/Test 本包测试用例 Scripts/Model/Test 仅测试使用的 ConditionExpr 模型节点 Luban/Config/Base ConditionExpr Luban bean 定义(__beans__.xlsx)核心源码文件与职责:
| 文件 | 职责 |
|---|---|
| ConditionExprLexer.cs | 词法分析,把表达式字符串切成 Token |
| ConditionExprParser.cs | 语法分析,递归下降构建行为树 |
| ConditionExprCompiler.cs | 对外编译入口,Lexer + Parser的封装 |
| ConditionVariableRegistry.cs | 变量名 → 节点类型 的注册表 |
| BTNumericCompare.cs | 普通数值比较节点 |
| ConditionExpr.Partial.cs | Luban bean 的EndInit编译钩子 |
二、Luban 配置字段与填写方式
2.1 Bean 定义
本包在Luban/Config/Base/__beans__.xlsx中定义了一个 Luban bean:
ET.ConditionExpr字段含义:
| 字段 | 类型 | 含义 |
|---|---|---|
Expr | string | 条件表达式 |
ErrorCode | int | 叶子条件没有显式错误码时使用的默认错误码 |
Desc | string | 策划备注,不参与运行时判断 |
2.2 在业务表中引用
任意业务表可以直接声明该 bean 类型的字段:
##var EnterCondition ##type ET.ConditionExpr ##comment 进入条件填写示例:
Expr = (HP >= 10 : 10001 || MP >= 100 : 10002) && Speed > 0 : 10003 ErrorCode = 9999 Desc = 进入副本条件2.3 编译时机:EndInit
配置对象创建时,Luban 生成的类会调用ConditionExpr.EndInit()。该 partial 方法(见 ConditionExpr.Partial.cs)把字符串交给编译器:
partial void EndInit() { this.Root = ConditionExprCompiler.Compile(this.Expr, this.ErrorCode); }编译结果保存在ConditionExpr.Root(类型为ConditionRoot)。也就是说,配置加载完成的那一刻,表达式就已经被编译成行为树,运行期不需要再做字符串解析,性能开销只在加载阶段发生一次。
三、表达式语法完整说明
3.1 比较运算
> >= < <= == !=对应枚举ConditionCompareOp(见 ConditionCompareOp.cs):Greater=1, GreaterEqual=2, Less=3, LessEqual=4, Equal=5, NotEqual=6。
3.2 逻辑运算
&& || ! ()运算优先级(与 C 系语言一致):
! > && > ||建议复杂条件显式加括号,例如:
(HP >= 10 : 10001 || MP >= 100 : 10002) && Speed > 0 : 100033.3 错误码绑定
每个叶子条件可以用冒号绑定显式错误码:
HP >= 10 : 10001如果叶子条件没写: errorCode,则使用 bean 上的默认ET.ConditionExpr.ErrorCode:
HP >= 10编译时默认错误码参数来自Compile(expr, defaultErrorCode)的第二个参数。
3.4 多 owner 语法:OwnerKey.Variable
当BTEnv中存在多个 owner 时,用点号指定从哪个 owner 读取变量:
Unit1.HP > 0 || Unit2.MP < 100约束:
OwnerKey.Variable只允许一层点号,Unit1.A.B这类多级引用会在编译期报错condition variable reference invalid(见 ConditionExprParser.cs);OwnerKey必须是调用方通过BTEnv.AddEntity(key, owner)传入的 owner key;- 目标节点必须声明
public string OwnerKey字段,否则编译时报错condition node owner key field not found; - 当前
BTNumericCompareHandler按现有NumericComponent契约读取Unit,即要求env.GetEntity<Unit>(node.OwnerKey)取到的实体带NumericComponent。
3.5 专用节点参数语法:NodeName(Param1, Param2)
专用条件节点支持括号传字符串参数:
Friend1(HP) > 0 Friend2(HP, MP) < 100规则:
Friend1、Friend2是通过ConditionVariableAttribute注册的节点名;HP、MP会按文本原样写入节点的public string[] Params字段;- 只有声明了
Params字段(类型必须是string[])的节点才能使用括号参数; - 数值节点(
BTNumericCompare)没有Params字段,所以HP(Friend1)这种写法会在编译期报错condition node params field not found(测试用例 Conditionexpr_Compile_Test.cs 专门验证了这一点); OwnerKey.Variable与括号参数不能混用于同一专用节点,测试用例验证Unit1.Friend1(HP) > 0会因目标节点缺少OwnerKey字段而报错。
四、编译原理:从字符串到行为树
编译入口是 ConditionExprCompiler.cs:
public static ConditionRoot Compile(string expr, int defaultErrorCode = 1) { ConditionExprLexer lexer = new(expr); List<ConditionToken> tokens = lexer.Tokenize(); ConditionExprParser parser = new(tokens, defaultErrorCode); return parser.Parse(); }4.1 词法分析(Lexer)
ConditionExprLexer.cs 逐字符扫描,产出 Token 序列(类型定义见 ConditionToken.cs):
- 标识符:以字母或
_开头,可含字母、数字、_、.(.被包含进标识符以便支持Unit1.HP这种 owner 引用); - 数字:支持
long解析,也支持负数字面量(-后跟数字);解析失败抛condition number parse error; - 运算符:
>/>=、</<=、=后必须跟=组成==、!/!=、&&、||、(、)、:、,。=或&或|后面没有配对字符时抛condition token error; - 空白:全部跳过,所以表达式里可以随意加空格;
- 扫描结束追加一个
End结束符。
4.2 语法分析(Parser)
ConditionExprParser.cs 采用递归下降 + 优先级分层:
ParseExpression → ParseOr → ParseAnd → ParseUnary → ParsePrimary → ParseCompareParseOr处理||,生成BTSelector;ParseAnd处理&&,生成BTSequence;ParseUnary处理!,生成BTNot(默认错误码取 defaultErrorCode,可被:覆盖);ParsePrimary处理括号(支持对括号组整体应用: errorCode)与叶子比较;ParseCompare解析变量 [OwnerKey.] [括号参数] 比较符 数值 [: errorCode],通过ConditionVariableRegistry查变量对应的节点类型,反射实例化节点并填充字段。
值得一提的优化:AddSequenceChild/AddSelectorChild会把连续的A && B && C摊平成单层BTSequence,避免嵌套行为树过深(见 ConditionExprParser.cs)。
4.3 字段填充与校验
编译期对节点字段做强校验:
OwnerKey字段必须存在且类型为string,否则抛condition node owner key field must be string;Params字段必须存在且类型为string[],否则抛condition node params field must be string[];- 错误码超出
int范围抛condition error code out of range; - 括号组错误码只允许作用在叶子节点或
!节点上,否则抛condition group error code only support leaf or not node。
从源码结构看,这套校验把大量"配置写错"的问题提前到编译期暴露,而不是拖到运行期才出错。
五、变量注册机制
5.1 普通数值变量
普通数值变量来自NumericType枚举。ConditionVariableRegistry.Awake()(见 ConditionVariableRegistry.cs)会:
- 遍历
NumericType的全部枚举值,把每个枚举名注册为变量,节点类型指向BTNumericCompare:HP -> BTNumericCompare, NumericType.HP MP -> BTNumericCompare, NumericType.MP Speed -> BTNumericCompare, NumericType.Speed - 通过
CodeTypes.Instance.GetTypes(typeof(ConditionVariableAttribute))扫描所有标记了[ConditionVariable("...")]的类,注册专用节点。
因此表达式中的变量名必须和NumericType常量名一致:
HP >= 10 MP >= 100 Speed > 0 Unit1.HP > 0 Unit2.MP < 100如果变量没有注册,编译时抛:
condition variable not registered: Xxx重复注册同一个变量名也会抛异常(duplicate condition variable: Xxx)。
5.2 注册表存放方式
注册结果保存在ConditionVariableRegistry的实例字段(variableNodeTypes/numericTypes两个字典)中,不使用静态字典。该注册表是一个挂在 ET 生命周期上的Singleton<ConditionVariableRegistry>,符合包 AGENTS 约定的"Attribute 扫描结果必须挂在 ET 生命周期对象上"。
六、运行时执行与错误码语义
6.1 返回值约定
行为树返回值约定:
0 条件成功 非 0 条件失败,值为错误码6.2 三种逻辑节点的短路语义
BTSequence对应&&:
从左到右执行 遇到第一个失败节点,直接返回该节点错误码 全部成功返回 0BTSelector对应||:
从左到右执行 任意子节点成功,返回 0 全部失败时,返回第一个失败节点的错误码BTNot对应!:
子节点失败时,! 成功,返回 0 子节点成功时,! 失败,返回 BTNot.ErrorCode6.3 示例推演
对表达式(HP >= 10 : 10001 || MP >= 100 : 10002) && Speed > 0 : 10003:
HP 满足且 Speed 满足 -> 0 HP 不满足但 MP 满足且 Speed 满足 -> 0 HP 和 MP 都不满足 -> 10001 HP 或 MP 满足但 Speed 不满足 -> 10003测试用例 Conditionexpr_Run_Test.cs 用真实Unit+NumericComponent验证了这三种返回路径(HP=5/MP=50 → 10001;MP=100 → 0;HP=10/Speed=0 → 10003)。
6.4 数值比较 Handler
运行时,叶子节点由 BTNumericCompareHandler.cs 执行:
Unit owner = env.GetEntity<Unit>(node.OwnerKey); NumericComponent numericComponent = owner.NumericComponent; long left = numericComponent.GetAsLong(node.NumericType); return ConditionCompareHelper.Compare(left, node.Op, node.Value) ? ErrorCode.ERR_Success : node.ErrorCode;比较逻辑集中在 ConditionCompareHelper.cs 的Compare(long left, ConditionCompareOp op, long right)中,未知操作符抛unknown condition compare op。
6.5 Root Handler
ConditionRootHandler.cs 是整棵条件树的入口:空树直接成功;子节点数量大于 1 抛异常;否则把唯一的子节点交给BTDispatcher执行。
七、运行期调用范式
7.1 使用配置对象中的 Root
配置加载完成后,直接用 Luban 对象里的Root:
ConditionExpr condition = mapConfig.EnterCondition; BTEnv env = BTEnv.Create(scene, unit.Id); try { env.AddEntity(ConditionExprEnvKeys.Unit, unit); int errorCode = BTHelper.RunTree(condition.Root, env); if (errorCode != ErrorCode.ERR_Success) { return errorCode; } } finally { env.Dispose(); }ConditionExprEnvKeys.Unit的值为字符串"Unit"(见 ConditionExprEnvKeys.cs)。裸变量(没有 owner 前缀的变量)默认从"Unit"这个 key 读取 owner,所以调用方必须用env.AddEntity(ConditionExprEnvKeys.Unit, unit)注册默认 owner。
7.2 直接编译字符串
也可以在代码里直接编译表达式字符串(跳过 Luban):
ConditionRoot root = ConditionExprCompiler.Compile( "(HP >= 10 : 10001 || MP >= 100 : 10002) && Speed > 0 : 10003", 9999);7.3 多 owner key 示例
ConditionRoot root = ConditionExprCompiler.Compile( "Unit1.HP > 0 || Unit2.MP < 100", 9999); BTEnv env = BTEnv.Create(scene, unit1.Id); try { env.AddEntity("Unit1", unit1); env.AddEntity("Unit2", unit2); int errorCode = BTHelper.RunTree(root, env); } finally { env.Dispose(); }带 owner key 的变量会读取表达式前缀对应的 key:Unit1.HP从 key"Unit1"取实体,Unit2.MP从 key"Unit2"取实体。运行测试 Conditionexpr_Run_Test.cs 用两个带NumericComponent的Unit实体验证了三条分支路径(双失败 → 20001;仅 HP 分支成功 → 0;仅 MP 分支成功 → 0)。
八、扩展专用变量节点
当某个变量不能从NumericComponent读取时(例如 VIP 等级、好友关系等业务数据),可以通过ConditionVariableAttribute注册专用节点,让它与BTNumericCompare平行工作。
8.1 定义一个专用节点
[ConditionVariable("VipLevel")] public class BTVipLevelCompare : BTCondition { public string[] Params; public ConditionCompareOp Op; public long Value; public int ErrorCode; }8.2 注册规则
专用节点必须继承 BTCondition 如果要接收表达式中的比较符、目标值和错误码,需要声明 Op / Value / ErrorCode public 字段 如果要支持 OwnerKey.Variable 语法,需要声明 OwnerKey public 字段,类型必须是 string 如果要支持 NodeName(Param1, Param2) 语法,需要声明 Params public 字段,类型必须是 string[] 变量名不能和已有 NumericType 或其它 ConditionVariableAttribute 重复 注册结果保存在 ConditionVariableRegistry 实例字段中,不使用静态字典其中Op/Value/ErrorCode三个字段是通过反射按BTNumericCompare的字段名写入的(见 ConditionExprParser.cs 的SetCompareField),因此字段名不能改动;OwnerKey与Params则由专门的SetOwnerKeyField/SetParamsField反射写入并校验类型。
BTNumericCompare和BTVipLevelCompare是平行关系,都继承BTCondition。专用节点的 Handler 负责按业务规则取值并比较,与数值 Handler 一样继承ABTHandler<T>即可。
测试用的专用节点 Conditionexpr_ParamCompareNode.cs 放在Scripts/Model/Test而非Scripts/Model/Share,遵循"只给测试使用的节点不要污染共享模型"的包约定。
九、测试与验证
本包测试位于Packages/cn.etetet.conditionexpr/Scripts/Hotfix/Test,共三个测试文件:
Conditionexpr_Compile_Test.cs:验证表达式编译结构(Root 子节点数量与类型)、NumericType注册、多 owner 解析、专用节点Params解析、以及两类编译期报错;Conditionexpr_LubanBean_Test.cs:验证 Luban bean 构造后EndInit自动编译,Root非空且叶子节点为BTNumericCompare;Conditionexpr_Run_Test.cs:在真实Scene+Unit+NumericComponent上跑行为树,验证错误码返回与多 owner 数值读取。
执行测试:
dotnet build ET.sln Remove-Item ./Logs -Recurse -Force -ErrorAction SilentlyContinue "Test --Name=Conditionexpr" | dotnet ./Bin/ET.App.dll --SceneName=Test覆盖点汇总:
表达式编译结构 Luban bean EndInit 编译 NumericType 注册到 BTNumericCompare 运行时错误码返回 多 owner key 数值读取 专用节点 Params 参数解析十、开发约定速查
最后,汇总本包 AGENTS 文档中的关键开发约束,方便后续在包内扩展时遵守:
只依赖已声明的底层包:behaviortree、unit、numeric 不新增静态注册表或静态缓存;Attribute 扫描结果必须挂在 ET 生命周期对象上 通用逻辑节点复用 cn.etetet.behaviortree 中的 BTSequence、BTSelector、BTNot ConditionExpr 自己的节点定义在本包内,不放回 cn.etetet.btnode 只给测试使用的 ConditionExpr 模型节点放在 Scripts/Model/Test OwnerKey.Variable 只允许一层点号,OwnerKey 必须是调用方已通过 BTEnv.AddEntity 传入的 key 专用条件节点支持 NodeName(Param1, Param2) 语法,没有 Params 字段的节点不能使用括号参数从整体看,ConditionExpr 的定位非常清晰:把策划填写的条件字符串在配置加载期编译为行为树,运行期由 ET 行为树调度器统一执行,错误码即条件失败原因。这种设计既让策划免于写 C# 代码,又让程序侧复用成熟的行为树执行体系,是配置驱动玩法逻辑的一个典型实践。
【免费下载链接】ETUnity3D Client And C# Server Framework项目地址: https://gitcode.com/GitHub_Trending/et/ET
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考