基于 Luban 的 Buff 系统使用与扩展指南
本文档详细说明了项目中 Buff 系统的实现原理、使用方法、配置方式以及如何扩展新的 Buff 逻辑。
1. 系统概述
该 Buff 系统是一个数据驱动 的、多态化的运行时效果系统。
- 配置层:使用 Luban 生成配置 (BuffInfo),支持嵌套 JSON 字符串来定义具体的业务逻辑。
- 逻辑层:通过 BuffHandler 管理生命周期,BaseBuffModule 定义具体行为。
- 多态性:Buff 的具体行为(如"护盾"、"造成伤害")通过 JSON 中的 $type 字段动态映射到对应的 C# 类。
2. 核心类说明
| 类名 | 路径 | 说明 |
|---|---|---|
| BuffHandler | HotUpdate/Buff/BuffHandler.cs | 挂载在角色/物体上的 MonoBehaviour。管理 Buff 列表,处理 Update (Tick) 和生命周期(添加、移除、叠加)。 |
| BuffInfo | HotUpdate/Buff/BuffDesign.cs | 运行时 Buff 数据对象。包含配置数据 (BuffData) 以及运行时的计时器、层数、施法者引用等。 |
| BaseBuffModule | HotUpdate/Buff/BaseBuffModule.cs | 所有具体 Buff 逻辑的基类。定义了 Apply 方法。 |
| BuffConverter | HotUpdate/Buff/BuffConverter.cs | 负责将配置中的 JSON 字符串解析为具体的 BaseBuffModule 实例。 |
3. 实现原理
3.1 生命周期回调
Buff 系统定义了多个触发时机(回调点),在配置表中通过 JSON 字符串配置:
- OnCreate: Buff 被添加或层数叠加时触发。
- OnTick: 根据 tickTime 间隔周期性触发。
- OnRemove: Buff 持续时间结束或被手动移除时触发。
- OnHit: (攻击者视角) 造成攻击时触发。
- OnBeHurt: (受击者视角) 受到伤害时触发。
- OnKill: (攻击者视角) 击杀目标时触发。
- OnBeKill: (受击者视角) 被击杀时触发。
3.2 运行流程
- 获取配置:从 GameTables.Instance.Tables.TbBuffInfo 获取原始 BuffInfo。
- 实例化模块:调用 BuffConverter.ConvertToBaseBuffModule,解析配置中的 JSON 字符串(如 OnCreate 字段),反序列化为对应的 BaseBuffModule 子类(如 Shield)。
- 添加 Buff:调用 buffHandler.AddBuff(buffInfo)。
- 触发逻辑:BuffHandler 在特定时机调用对应 Module 的 Apply(buffInfo) 方法执行逻辑。
4. 如何使用 (代码调用)
在代码中给角色添加 Buff 的标准范例:
cs
// 1. 获取 BuffHandler 组件
var buffHandler = targetGameObject.
GetComponent<BuffHandler>();
if (buffHandler == null) return;
// 2. 从配表获取 Buff 配置 (假设 ID 为 1001)
int buffId = 1001;
BuffInfo originBuffInfo =
GameTables.Instance.Tables.
TbBuffInfo.Get(buffId);
if (originBuffInfo == null) {
Debug.LogError($"Buff ID
{buffId} 不存在");
return;
}
// 3. 转换为运行时数据 (解析内部 JSON)
BuffInfo buffInfo = BuffConverter.
ConvertToBaseBuffModule(ref
originBuffInfo);
// 4. 设置运行时上下文 (必须设置
creator 和 target)
buffInfo.creator =
myGameObject; // 施法者
buffInfo.target =
targetGameObject; // 承受者
// 5. 施加 Buff
buffHandler.AddBuff(buffInfo);
5. 如何配置 (excel 表)
5.1 基础字段
- id: Buff 唯一 ID。
- buffData:
- duration: 持续时间(秒)。
- tickTime: 周期触发间隔(秒)。
- maxStack: 最大叠加层数。
- isForever: 是否永久(忽略 duration)。
- buffUpdateTime: 叠加规则 (1: Add 叠加时间, 2: Replace 重置时间, 3: Keep 保持不变)。
- buffRemoveStackUpdate: 移除规则 (1: Clear 直接清除, 2: Reduce 减少一层)。
5.2 逻辑字段 (Polymorphic JSON)
OnCreate, OnTick 等字段是 字符串 类型的,内容是 转义后的 JSON。 必须包含 "$type" 字段来指定对应的 C# 类名。

6. 如何新增 Buff 代码
如果你需要一个新的 Buff 效果(例如:"无敌"),请按照以下步骤操作:
步骤 1: 创建 C# 类
在 Assets/HotUpdate/Buff/BaseBuffModule/ 目录下新建脚本 Invincible.cs。 类必须继承自 BaseBuffModule (注意是 partial 类),并放在 cfg.Buff 命名空间下。
cs
using UnityEngine;
namespace cfg.Buff
{
// 类名必须与配置中 $type 一致
public partial class
Invincible : BaseBuffModule
{
// 如果配置中有额外参数,直接定
义 public 字段,名称与 JSON
key 一致
public float
extraEffectValue;
public override void Apply
(BuffInfo buffInfo,
DamageInfo damageInfo =
null)
{
// 在这里编写具体逻辑
if (buffInfo.target !=
null)
{
Debug.Log($"
{buffInfo.target.
name} 进入无敌状态!
额外参数:
{extraEffectValue}
");
// 示例:获取目标身上
的某个组件并修改状态
// var health =
buffInfo.target.
GetComponent<Health
>();
// health.
SetInvincible
(true);
}
}
// 如果需要在 Buff 移除时执行
逻辑(如取消无敌),
// 可以在配置的 OnRemove 字段
填入同样的类型 "{ \"$type\":
\"Invincible\" }"
// 然后在 Apply 中根据上下文判
断,或者写两个不同的类(如
InvincibleStart 和
InvincibleEnd)
// *通常建议写两个不同的类,或
者在 Apply 内部无法区分是
Create 还是 Remove 调用的,
// 除非通过传入的额外参数区分,
例如 { "$type":
"Invincible", "enable":
true }*
}
}
注意:目前的架构中,Apply 方法本身不知道它是被 OnCreate 还是 OnRemove 调用的。
- 如果开启和关闭逻辑不同,建议写两个类,例如 InvincibleOn 和 InvincibleOff。
- 或者同一个类加一个 bool isEnable 字段,配置时分别填 true 和 false。
步骤 3: 重新生成代码 (如果涉及 Luban 结构变更)
如果是纯逻辑代码新增,通常不需要重新生成 Luban 代码。但如果 BaseBuffModule 的反序列化逻辑是生成的,可能需要确保新类能被识别(依赖于具体的反序列化实现,通常 SimpleJSON 配合反射或生成的解析器工作)。从现有代码看,BuffConverter 使用 BaseBuffModule.DeserializeBaseBuffModule,这通常是 Luban 生成的工厂方法。
- 重要:如果 DeserializeBaseBuffModule 是自动生成的,你可能需要运行 Luban 生成脚本,或者手动在生成的 BaseBuffModule 分部类中注册你的新类型(取决于项目具体的 Luban 模板配置)。
7. 现有常用 Buff 模块参考
- Shield: 给予护盾 (shieldCount, baseDamage 等参数)。
- CasuingDamage: 造成伤害 (DamgePerHit)。
- SpawnObjectBM: 生成特效/物体 (objPath, localPosition, delayDestroyTime)。
- ChangeCharacterPropertyBM: 修改角色属性 (attackDamage, bulletSpeedPoints 等)。
- PowerfulBuffs: 复合 Buff,一次性添加多个 ID 的 Buff (buffIdList)。
上面是我让AI写的说明,写的8成是对的,还是可以的,但是还是要做几处补充说明
因为excel表里不能配置类,所以需要新建xml配置,让luban自动生成分部类,放到代码里面去自己建立对应分部,补全另一半。
XML
<module name="Buff">
<!-- 抽象基类 -->
<bean name="BaseBuffModule">
</bean>
<!-- 具体逻辑 Bean: 修改角色属性 -->
<bean name="ChangeCharacterPropertyBM" parent="BaseBuffModule">
<var name="shootCooldownPoints" type="float?" comment="射击间隔比率"/>
<var name="reloadSpeedPoints" type="float?" comment="换弹速度比率"/>
<var name="bulletSpeedPoints" type="float?" comment="子弹飞行速度点数"/>
<var name="rowAmmo" type="int?" comment="弹药排数 加减 0"/>
<var name="attackDamage" type="float?" comment="增加的攻击力"/>
<var name="attackDamagePoints" type="float?" comment="增加的攻击力百分比"/>
<var name="criticalRate" type="float?" comment="暴击率 直接加减"/>
<var name="criticalDamageRate" type="float?" comment="暴击伤害百分比 直接加减"/>
<var name="ultimateSkillCooldownPoints" type="float?" comment="终极技能减少时间倍率"/>
<var name="ultimateSkillCooldownSeconds" type="float?" comment="终极技能减少的秒数"/>
<var name="bulletPenetrationCount" type="int?" comment="子弹能穿透的次数"/>
</bean>
<!-- 修改怪物属性 -->
<bean name="ChangeEnemyPropertyBM" parent="BaseBuffModule">
<var name="duration" type="float?" comment="持续时间"/>
<var name="attackDamage" type="float?" comment="直接增加或减少的攻击力"/>
<var name="attackDamagePoints" type="float?" comment="攻击力点数"/>
<var name="moveSpeedPoints" type="float?" comment="移动速度点数"/>
<var name="Knockback" type="float?" comment="被击退距离"/>
</bean>
<!-- 里面塞多种buffId,触发时施加里面的buffId -->
<!-- 例如生成炸弹并清楚所有敌人就先写两个buff,再把这两个buff填进来 -->
<bean name="PowerfulBuffs" parent="BaseBuffModule">
<var name="buffIdList" type="(list#sep=,),int"/>
</bean>
<!-- 造成伤害 放在OnCreate里就是DPH,放在OnTick里就是DPS -->
<bean name="CasuingDamage" parent="BaseBuffModule">
<var name="damgePerHit" type="float"/>
</bean>
<!--原清屏效果-->
<bean name="ClearAllEnemies" parent="BaseBuffModule">
</bean>
<!--子弹散射分裂效果-->
<bean name="BulletScatterSplit" parent="BaseBuffModule">
<var name="splitNumber" type="int" comment="分裂子弹数"/>
<var name="bulletDamageRate" type="float" comment="分裂伤害系数数"/>
</bean>
<!-- 时间流速减缓 scaleRate:0.7,时间流速变为70% -->
<bean name="SlowTime" parent="BaseBuffModule">
<var name="scaleRate" type="float"/>
<!-- buff表里面已经写了buff 的持续时间 这里不用再写了 只要在 OnRemove的时候还原就好了 -->
<!-- var name="duration" type="float"/ -->
</bean>
<!-- 影分身效果 -->
<bean name="ShadowClone" parent="BaseBuffModule">
<!-- 这里生成的是个预制体,配表拉不到这个预制体所以需要持续时间后销毁 -->
<var name="duration" type="float"/>
</bean>
<!-- 生成护盾 -->
<bean name="Shield" parent="BaseBuffModule">
<var name="shieldCount" type="int" comment="护盾抵挡次数 "/>
<var name="baseDamage" type="float" comment="破碎时造成基础伤害"/>
<var name="maxHealthDamageRate" type="float" comment="造成怪物生命上限伤害百分比"/>
</bean>
<!-- 生成某个物体,附带生成坐标 -->
<bean name="SpawnObjectBM" parent="BaseBuffModule">
<var name="objPath" type="string"/>
<var name="localPosition" type="vector3"/>
<var name="delayDestroyTime" type="float?"/>
<var name="triggerBuffId" type="int?" comment="物体生成时可以给他一个id,当敌人经过时会触发这个id的buff效果"/>
</bean>
<!-- 生成某个物体会附着到目标身上,附带生成坐标 -->
<bean name="SpawnObjWithTarget" parent="BaseBuffModule">
<var name="objPath" type="string"/>
<var name="offsetPos" type="vector3"
comment="物体在他身上的偏移位置"/>
<var name="delayDestroyTime" type="float?"/>
</bean>
<!-- Buff 配置表 -->
<bean name="BuffData">
<var name="id" type="int"/>
<var name="buffName" type="string"/>
<var name="description" type="string"/>
<var name="icon" type="string?"/>
<var name="priority" type="int"/>
<var name="maxStack" type="int"/>
<!-- <var name="tags" type="(list#sep=,),string"/> -->
<var name="isForever" type="bool"/>
<var name="duration" type="float"/>
<var name="tickTime" type="float"/>
<var name="buffUpdateTime" type="Buff.BuffUpdateTimeEnum"/>
<var name="buffRemoveStackUpdate" type="Buff.BuffRemoveStackUpdateEnum"/>
<!-- 多态回调字段:直接使用多态类型 -->
<var name="OnCreate" type="BaseBuffModule?"/>
<var name="OnRemove" type="BaseBuffModule?"/>
<var name="OnTick" type="BaseBuffModule?"/>
<var name="OnHit" type="BaseBuffModule?"/>
<var name="OnBeHurt" type="BaseBuffModule?"/>
<var name="OnKill" type="BaseBuffModule?"/>
<var name="OnBeKill" type="BaseBuffModule?"/>
</bean>
</module>
这里定义了,luban在自动生成代码的时候就会生成对应的分部,但是生成的类使用的模板默认是readonly的,所以我在模板里面把readonly去掉了
因为在Luban的excel里面不能直接写类,所以后面有一个string类型的OnCreate等回调点,在代码里面,第一次调用的时候会将这个json解析成类回来一次
bash
using Luban;
using SimpleJSON;
{{
parent_def_type = __bean.parent_def_type
export_fields = __bean.export_fields
hierarchy_export_fields = __bean.hierarchy_export_fields
}}
{{namespace_with_grace_begin __namespace_with_top_module}}
{{~if __bean.comment != '' ~}}
/// <summary>
/// {{escape_comment __bean.comment}}
/// </summary>
{{~end~}}
{{~
func get_ref_name
ret (format_property_name __code_style $0.name) + '_Ref'
end
func get_index_var_name
ret (format_property_name __code_style $0.name) + '_Index'
end
func generate_resolve_field_ref
field = $0
fieldName = format_property_name __code_style field.name
refTable = get_ref_table field
if can_generate_ref field
tableName = format_property_name __code_style refTable.name
if field.is_nullable
ret (get_ref_name field) + ' = ' + fieldName + '!= null ? tables.' + tableName + '.GetOrDefault(' + (get_value_of_nullable_type field.ctype fieldName) + ') : null;'
else
ret (get_ref_name field) + ' = tables.' + tableName + '.GetOrDefault(' + fieldName + ');'
end
else if can_generate_collection_ref field
collection_ref_table = get_collection_ref_table field
tableName = format_property_name __code_style collection_ref_table.name
if field.ctype.type_name == 'list' || field.ctype.type_name == 'set'
line1 = (get_ref_name field) + ' = new ' + (declaring_collection_ref_name field.ctype) + '();' + '\n'
line2 = 'foreach (var _v in ' + fieldName + ') { ' + (get_ref_name field) + '.Add(tables.' + tableName + '.GetOrDefault(_v)); }' + '\n'
ret line1 + line2
else if field.ctype.type_name == 'array'
line1 = (get_ref_name field) + ' = new ' + (declaring_type_name collection_ref_table.value_ttype) + '[' + fieldName + '.Length];' + '\n'
line2 = 'for (int _i = 0; _i < ' + fieldName + '.Length; _i++) { ' + (get_ref_name field) + '[_i] = tables.' + tableName + '.GetOrDefault(' + fieldName + '[_i]); }' + '\n'
ret line1 + line2
else if field.ctype.type_name == 'map'
line1 = (get_ref_name field) + ' = new ' + (declaring_collection_ref_name field.ctype) + '();' + '\n'
line2 = 'foreach (var kvp in ' + fieldName + ') { ' + (get_ref_name field) + '.Add(kvp.Key, tables.' + tableName + '.GetOrDefault(kvp.Value)); }' + '\n'
ret line1 + line2
else
ret ''
end
else
if (is_field_bean_need_resolve_ref field)
ret fieldName + '?.ResolveRef(tables);'
else if (is_field_array_like_need_resolve_ref field)
ret 'foreach (var _e in ' + fieldName + ') { _e?.ResolveRef(tables); }'
else if (is_field_map_need_resolve_ref field)
ret 'foreach (var _e in ' + fieldName + '.Values) { _e?.ResolveRef(tables); }'
else
ret ''
end
end
end
~}}
{{~if __bean.is_value_type~}}
public partial struct {{__name}}
{{~else~}}
public {{class_modifier __bean}} partial class {{__name}} : {{if parent_def_type}}{{__bean.parent}}{{else}}Luban.BeanBase{{end}}
{{~end~}}
{
public {{__name}}(JSONNode _buf) {{if parent_def_type}} : base(_buf) {{end}}
{
{{~ for field in export_fields
fieldName = format_property_name __code_style field.name
~}}
{{deserialize_field fieldName '_buf' field.name field.ctype}}
{{~if can_generate_ref field~}}
{{get_ref_name field}} = null;
{{~end~}}
{{~if has_index field~}}
foreach(var _v in {{fieldName}})
{
{{get_index_var_name field}}.Add(_v.{{format_property_name __code_style (get_index_field field).name}}, _v);
}
{{~end~}}
{{~end~}}
}
{{~if !__bean.is_value_type~}}
public {{__name}}() {{if parent_def_type}} : base() {{end}}
{
}
{{~end~}}
public {{__name}}({{__name}} other) {{if parent_def_type}} : base(other) {{end}}
{
{{~ for field in export_fields
fieldName = format_property_name __code_style field.name
~}}
this.{{fieldName}} = other.{{fieldName}};
{{~if can_generate_ref field~}}
this.{{get_ref_name field}} = other.{{get_ref_name field}};
{{~end~}}
{{~if has_index field~}}
foreach(var _v in other.{{get_index_var_name field}})
{
this.{{get_index_var_name field}}.Add(_v.Key, _v.Value);
}
{{~end~}}
{{~end~}}
}
public static {{__name}} Deserialize{{__name}}(JSONNode _buf)
{
{{~if __bean.is_abstract_type~}}
switch ((string)_buf["$type"])
{
{{~for child in __bean.hierarchy_not_abstract_children~}}
case "{{impl_data_type child __bean}}": return new {{child.full_name}}(_buf);
{{~end~}}
default: throw new SerializationException();
}
{{~else~}}
return new {{__bean.full_name}}(_buf);
{{~end~}}
}
{{~ for field in export_fields ~}}
{{~if field.comment != '' ~}}
/// <summary>
/// {{escape_comment field.comment}}
/// </summary>
{{~end~}}
public {{declaring_type_name field.ctype}} {{format_property_name __code_style field.name}};
{{~if can_generate_ref field~}}
public {{declaring_type_name (get_ref_type field)}} {{get_ref_name field}};
{{~else if can_generate_collection_ref field~}}
public {{declaring_collection_ref_name field.ctype}} {{get_ref_name field}};
{{~end~}}
{{~if has_index field
indexMapType = get_index_map_type field
~}}
public readonly {{declaring_type_name indexMapType}} {{get_index_var_name field}} = new {{declaring_type_name indexMapType}}();
{{~end~}}
{{~end~}}
{{~if !__bean.is_abstract_type && !__bean.is_value_type~}}
public const int __ID__ = {{__bean.id}};
public override int GetTypeId() => __ID__;
{{~end~}}
public {{method_modifier __bean}} void ResolveRef({{__manager_name}} tables)
{
{{~if parent_def_type~}}
base.ResolveRef(tables);
{{~end~}}
{{~for field in export_fields~}}
{{~ line = generate_resolve_field_ref field ~}}
{{~ if line != ''~}}
{{line}}
{{~end~}}
{{~end~}}
}
public override string ToString()
{
return "{{full_name}}{ "
{{~for field in hierarchy_export_fields ~}}
+ "{{format_field_name __code_style field.name}}:" + {{to_pretty_string (format_property_name __code_style field.name) field.ctype}} + ","
{{~end~}}
+ "}";
}
}
{{namespace_with_grace_end __namespace_with_top_module}}