01-从技能脚本到能力系统
第 1 章:从技能脚本到能力系统
问题导入
技能系统最容易让人产生错误的安全感:单机原型里,按键、动画、投射物和伤害都能在一个 Cast() 方法中跑通,于是我们很自然地认为,剩下的工作只是继续往方法里加几个判断。
火球术一开始确实可以很简单。玩家按下按键,角色抬手,生成一个火球,命中敌人后造成伤害。但当需求变成“消耗 Mana、拥有冷却、施加灼烧、可被眩晕打断,还要支持客户端预测和服务器校正”时,真正增加的不是几行代码,而是状态的数量、生命周期和所有权。
1. 先让单机火球术跑起来
1.1 最小需求并不需要完整架构
假设这是一个只在本地运行的战斗原型。玩家按下按键后,角色播放施法动作,火球飞向目标,命中后直接造成 120 点伤害。
示例代码:下面的
Enemy、Projectile和PlayFireballAnimation只是为了表达流程的伪接口,不代表 Unity 内置 API。
public sealed class FireballSkill : MonoBehaviour{ [SerializeField] private int directDamage = 120;
public void Cast(Enemy target) { PlayFireballAnimation(); Projectile projectile = SpawnProjectile(target); projectile.OnHit += () => target.TakeDamage(directDamage); }}这样写似乎没什么问题。
directDamage 是一个固定配置,target 是这次施法的输入,火球命中后只发生一次伤害。没有多人同步,也没有需要跨多个系统共享的持续状态。
此时的状态关系很简单:
FireballSkill -> 保存火球伤害配置 -> 接收本地输入 -> 创建投射物 -> 监听命中 -> 调用目标的受伤入口但是,只要火球术要修改角色 Mana、记录冷却,或给目标挂一个持续 5 秒的 Burning,它就不再只是一个投射物入口。
1.2 加入 Mana
现在火球术需要消耗 30 Mana。最直接的写法是把 Mana 也放进技能脚本:
示例代码:这是有意保留的过渡写法,用来展示职责如何开始混合。
public sealed class FireballSkill : MonoBehaviour{ [SerializeField] private int mana = 100; [SerializeField] private int manaCost = 30; [SerializeField] private int directDamage = 120;
public void Cast(Enemy target) { if (mana < manaCost) return;
mana -= manaCost; PlayFireballAnimation(); SpawnProjectile(target, directDamage); }}它仍然能运行,但 mana 的归属已经变得可疑。Mana 通常属于角色,而不是某一个技能:
- 其他技能也会消耗 Mana;
- 回复药、装备、被动和 Buff 都可能修改 Mana;
- UI 需要显示它;
- 存档或网络快照需要保存它;
- 服务器需要验证它是否足够。
如果每个技能都拥有一份自己的 Mana,系统会出现“火球脚本里的 Mana”和“角色面板里的 Mana”两个事实来源。即使把 Mana 放进角色脚本,技能仍然需要知道角色内部字段和扣除时机,久而久之,每个能力都会复制一套资源判断。
这里出现了第一个状态所有权判断:
长期存在、会被多个系统读写、需要同步或保存的数值,通常属于 Actor 的属性上下文,而不是某个 Ability 的私有字段。
1.3 加入 Cooldown
下一步给火球术增加 2 秒冷却:
示例代码:
cooldownRemaining仍然是过渡实现,用于说明冷却和 Mana 不是同一种状态。
public sealed class FireballSkill : MonoBehaviour{ [SerializeField] private int directDamage = 120; [SerializeField] private int manaCost = 30; [SerializeField] private float cooldownSeconds = 2f;
private float cooldownRemaining;
public void Update() { cooldownRemaining = MathF.Max(0f, cooldownRemaining - Time.deltaTime); }
public void Cast(Enemy target) { if (cooldownRemaining > 0f) return;
if (!TrySpendMana(manaCost)) return;
cooldownRemaining = cooldownSeconds; PlayFireballAnimation(); SpawnProjectile(target, directDamage); }}冷却看起来也是一个数字,但它和 Mana 的生命周期不同:
- Mana 是角色的持续属性,数值可能一直存在;
- Cooldown 是一次激活产生的临时状态,2 秒后到期;
- 冷却可能需要显示剩余时间;
- 某些规则可能缩短、重置或禁止冷却;
- 取消发生在不同阶段时,是否保留冷却需要明确约定。
如果一个技能脚本自己递减冷却,就意味着它还要负责时钟、复制、暂停、到期通知和取消清理。多个技能各自维护类似的 Update() 后,系统会出现很多几乎一样但细节不同的计时器。
此时应该把问题分成两类:Mana 是“当前有多少”,Cooldown 是“某次能力激活留下了什么临时效果”。它们都可能最终由同一个运行时上下文协调,但不应该因为都能用 float 表示,就被当作同一种字段。
2. 从数值状态走向持续状态
2.1 加入 Burning
火球命中后不只造成 120 点直接伤害,还要给目标施加 Burning,持续 5 秒、每 1 秒结算一次。
最容易写出的版本,是让 FireballSkill 自己保存灼烧计时器:
private void ApplyBurning(Enemy target){ float remaining = 5f;
while (remaining > 0f) { target.TakeBurningTick(); remaining -= 1f; }}这个例子甚至没有正确表达异步,但它暴露了关键问题:灼烧到底属于谁?
- 火球术负责创建它,因为灼烧来自火球;
- 目标负责承载它,因为灼烧影响目标的状态;
- 统一效果系统负责计时,因为还会有中毒、流血、减速和护盾;
- 服务器负责决定它是否真的生效,因为目标状态不能由客户端单方面改变。
更稳妥的语义是把这次命中拆成两种变化:
命中瞬间: 对目标应用一次直接伤害 120
持续期间: 对目标添加 Burning 状态 持续 5 秒 每 1 秒触发一次周期结算 到期后移除 Burning 及其相关运行时状态这里有一个重要的工程判断:创建状态的能力,不等于拥有状态的对象。火球术可以提出“施加 Burning”的意图;目标的运行时上下文应该承载这份状态,并让统一系统负责它的周期和到期。
2.2 Buff 不只是“加一个字段”
把 Burning 换成一个给施法者增加攻击力的 Buff,问题会更明显。Buff 可能有:
- 来源和目标;
- 持续时间;
- 是否可以叠加;
- 叠加时刷新时间还是增加层数;
- 到期时如何撤销数值修改;
- 断线重连时是否需要恢复;
- 服务器拒绝预测时是否需要回滚。
如果这些规则都写进施加 Buff 的技能里,那么每种 Buff 都会有自己的计时、叠加和清理实现。真正可复用的抽象不是“BuffBase”这个名字,而是“一个有来源、目标、持续策略和可撤销变化的运行时效果”。
这也是为什么后续章节会把“能力流程”和“效果生命周期”分开讨论:能力决定何时提出变化,效果决定变化如何存在。
3. 加入打断
3.1 “正在施法”
假设火球术不再按下按键就立即命中,而是经历以下过程:
输入 -> 开始施法 -> 播放抬手动画 -> 等待投射物生成点 -> 生成火球 -> 等待命中 -> 应用直接伤害和 Burning -> 收招并结束只要角色在“等待投射物生成点”时被眩晕,技能就必须停止。问题不仅仅是加一句 if (isStunned) return:
- 已经添加的
Casting状态是否移除; - 已经播放的蓄力表现是否停止;
- Mana 已经扣除时是否退回 30;
- Cooldown 已经提交时是否保留 2 秒;
- 尚未生成的投射物是否销毁;
- 等待动画事件或命中事件的回调是否注销;
- 客户端的预测记录是否标记为取消。
传统脚本经常只在入口检查一次眩晕:
public void Cast(Enemy target){ if (isStunned) return;
PlayCastAnimation(); // 后续动画事件、协程和回调仍可能继续执行。}这段代码的问题是,isStunned 只影响“能不能开始”,没有影响“已经开始的流程如何结束”。技能一旦跨越多个帧,就需要一个真正的运行时生命周期,而不是一个入口布尔值。
3.2 取消是状态转换,不是提前返回
可以把一次火球术的运行时状态简化为:
Idle -> Casting -> WaitingForTarget -> ProjectileLaunched -> Resolving -> Completed
Casting / WaitingForTarget / ProjectileLaunched -> CancelledCancelled 不是“什么都没发生”。它需要明确哪些副作用已经提交,哪些还没有提交。比如项目约定在投射物生成时才扣 Mana,那么抬手阶段被眩晕可以不扣资源;如果项目约定激活成功就扣 Mana,那么取消时可能需要保留消耗,或者由专门规则退回。
通用原则是:
每个可取消的流程,都必须定义取消点、已提交状态和清理动作。
这也是状态所有权变复杂的分水岭。技能流程本身可以由 Ability 运行时拥有,但 Casting、Cooldown、Burning 和 Mana 并不因此都应该塞进同一个对象。
4. 加入网络:同一个状态出现多个副本
4.1 客户端看到的结果不等于最终结果
多人游戏里,客户端希望按键后马上有反馈,所以可能会本地播放动画、生成预测投射物,甚至先显示命中特效。服务器则必须重新判断:
- 这个角色是否真的拥有火球术;
- 请求是否来自该角色的合法输入;
- 当前 Mana 是否至少为 30;
Cooldown.Fireball是否不存在或已经到期;- 角色是否处于眩晕、沉默或其他阻断状态;
- 目标和方向是否在允许范围内;
- 这个请求是否已经处理过。
客户端可以发送“我想施放火球、目标大概在这里”的请求,但不能直接发送“目标已经受到 120 点伤害并进入 Burning ”作为事实。最终伤害、资源扣除、冷却和持续状态都必须由权威端确认。
4.2 状态所有权分层
将火球术放进联机环境后,至少要区分以下几类状态:
| 状态 | 更合理的所有者 | 客户端能做什么 |
|---|---|---|
| 火球术的 30、2 秒、120、5 秒、1 秒配置 | 静态能力定义 | 读取配置并用于本地表现 |
| 角色当前 Mana | 服务器上的角色属性上下文 | 显示复制值,可做预测展示 |
| Cooldown 的权威开始和结束时间 | 服务器上的运行时效果 | 显示倒计时,必要时预测本地 UI |
| Burning 是否生效及其周期 | 目标的服务器运行时状态 | 播放预测或确认后的表现 |
| 本次施法正在等待什么 | 各端对应的能力运行时 | 本地可先响应,服务器决定是否成立 |
| 直接伤害 120 的最终结算 | 服务器规则层 | 根据结果更新本地复制状态 |
| 动画、粒子、音效和镜头 | 每个客户端的表现层 | 允许预测,但需接受校正 |
注意:“每个客户端都有一份”不等于“每份都能修改事实”。客户端可以拥有本地副本,但副本的更新权限、预测范围和校正方式必须明确。
4.3 网络请求会放大脚本耦合
如果仍然由 FireballSkill : MonoBehaviour 负责全部事情,它现在还要处理:
本地输入 -> 本地检查 -> 本地预测 -> 发送请求 -> 等待服务器结果 -> 接受、拒绝或回滚 -> 处理重复和乱序 -> 清理能力运行时这时最重要的抽象不是“让客户端和服务器各有一个 FireballSkill”,而是让两端复用同一套可测试的规则语义,把输入意图、运行时状态、服务器结果和表现事件分开。
5. 传统 Skill MonoBehaviour 反例
下面把前面的需求故意重新塞回一个脚本:
示例代码:
Enemy、Animator、SendCastRequestToServer和ApplyBurning的具体实现未展示。这里的重点是职责混合,不是提供可直接运行的 Unity 组件。
public sealed class FireballSkill : MonoBehaviour{ public int mana = 100; public int manaCost = 30; public float cooldownRemaining; public float cooldownSeconds = 2f; public int directDamage = 120; public bool isStunned;
private bool isCasting; private Enemy currentTarget;
public void Update() { cooldownRemaining = MathF.Max(0f, cooldownRemaining - Time.deltaTime);
if (isStunned && isCasting) CancelCast(); }
public void Cast(Enemy target) { if (isStunned || cooldownRemaining > 0f || mana < manaCost) return;
mana -= manaCost; cooldownRemaining = cooldownSeconds; isCasting = true; currentTarget = target;
GetComponent<Animator>().Play("Cast"); SendCastRequestToServer(target); }
private void OnProjectileHit() { currentTarget.TakeDamage(directDamage); ApplyBurning(currentTarget, duration: 5f, period: 1f); isCasting = false; }
private void CancelCast() { isCasting = false; currentTarget = null; // 这里还要决定是否退回 Mana、移除表现和回滚预测。 }}这个反例的问题不是 MonoBehaviour 本身。Unity 的 MonoBehaviour 很适合作为输入桥接、场景生命周期入口或表现组件。问题在于一个脚本同时拥有了:
- 火球术的静态配置;
- 角色 Mana 的属性;
- 冷却的运行时生命周期;
- 眩晕条件;
- 本次施法的目标和中间态;
- 命中后的直接伤害;
- Burning 的持续状态;
- 动画播放;
- 网络请求和未来的服务器结果;
- 取消、回滚和清理。
当这些职责混在一起时,测试也会被迫依赖场景。想验证“Mana 等于 30 时允许施法”,可能要创建 GameObject、挂组件、初始化 Animator;想验证“服务器拒绝后撤销 Burning”,又要模拟网络回调和粒子状态。规则越多,测试成本越高。
6. 什么时候需要 GAS 风格架构
不是每个项目都值得引入完整 GAS,也不是只有使用 UE 才需要 Ability、Effect 和 Tag 这样的语义。可以用下面的判断标准做技术评估:
| 信号 | 说明 |
|---|---|
| 多个技能共享资源、冷却或阻断规则 | 同一类逻辑开始在多个脚本复制 |
| 有持续、周期、叠加或可撤销状态 | 状态需要统一生命周期,而不是各自写计时器 |
| 技能存在读条、目标确认或命中等待 | 能力已经不是单帧函数,需要可取消流程 |
| 角色会被眩晕、沉默、死亡或硬直打断 | 入口判断不足以管理中间态 |
| 客户端与服务器都要执行相关规则 | 需要明确哪些是预测,哪些是权威 |
| 需要在不启动场景时测试规则 | 表现和战斗逻辑必须分离 |
| 设计师或策划会持续组合新的效果 | 静态定义与运行时实例需要分开 |
一个实用的分级方式是:
- 只有单机、技能很少、没有共享状态:继续使用轻量脚本没有问题,但仍应避免让表现组件拥有角色属性。
- 出现共享资源、Buff、打断或大量技能:优先引入 Tag、Effect 生命周期和独立的能力运行时,不必一次实现完整 GAS。
- 需要多人战斗、客户端预测、服务器拒绝和状态回滚:至少需要明确的能力流程、属性存储、效果实例、事件/表现边界和权威协议。
通用原则:采用 GAS 风格架构的理由,应当是它能降低项目已经出现的复杂度,而不是因为类名听起来更专业。