01-从技能脚本到能力系统

3870 字
19 分钟
01-从技能脚本到能力系统

第 1 章:从技能脚本到能力系统#

问题导入#

技能系统最容易让人产生错误的安全感:单机原型里,按键、动画、投射物和伤害都能在一个 Cast() 方法中跑通,于是我们很自然地认为,剩下的工作只是继续往方法里加几个判断。

火球术一开始确实可以很简单。玩家按下按键,角色抬手,生成一个火球,命中敌人后造成伤害。但当需求变成“消耗 Mana、拥有冷却、施加灼烧、可被眩晕打断,还要支持客户端预测和服务器校正”时,真正增加的不是几行代码,而是状态的数量、生命周期和所有权。

1. 先让单机火球术跑起来#

1.1 最小需求并不需要完整架构#

假设这是一个只在本地运行的战斗原型。玩家按下按键后,角色播放施法动作,火球飞向目标,命中后直接造成 120 点伤害。

示例代码:下面的 EnemyProjectilePlayFireballAnimation 只是为了表达流程的伪接口,不代表 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
-> Cancelled

Cancelled 不是“什么都没发生”。它需要明确哪些副作用已经提交,哪些还没有提交。比如项目约定在投射物生成时才扣 Mana,那么抬手阶段被眩晕可以不扣资源;如果项目约定激活成功就扣 Mana,那么取消时可能需要保留消耗,或者由专门规则退回。

通用原则是:

每个可取消的流程,都必须定义取消点、已提交状态和清理动作。

这也是状态所有权变复杂的分水岭。技能流程本身可以由 Ability 运行时拥有,但 Casting、Cooldown、Burning 和 Mana 并不因此都应该塞进同一个对象。

4. 加入网络:同一个状态出现多个副本#

4.1 客户端看到的结果不等于最终结果#

多人游戏里,客户端希望按键后马上有反馈,所以可能会本地播放动画、生成预测投射物,甚至先显示命中特效。服务器则必须重新判断:

  1. 这个角色是否真的拥有火球术;
  2. 请求是否来自该角色的合法输入;
  3. 当前 Mana 是否至少为 30;
  4. Cooldown.Fireball 是否不存在或已经到期;
  5. 角色是否处于眩晕、沉默或其他阻断状态;
  6. 目标和方向是否在允许范围内;
  7. 这个请求是否已经处理过。

客户端可以发送“我想施放火球、目标大概在这里”的请求,但不能直接发送“目标已经受到 120 点伤害并进入 Burning ”作为事实。最终伤害、资源扣除、冷却和持续状态都必须由权威端确认。

4.2 状态所有权分层#

将火球术放进联机环境后,至少要区分以下几类状态:

状态更合理的所有者客户端能做什么
火球术的 30、2 秒、120、5 秒、1 秒配置静态能力定义读取配置并用于本地表现
角色当前 Mana服务器上的角色属性上下文显示复制值,可做预测展示
Cooldown 的权威开始和结束时间服务器上的运行时效果显示倒计时,必要时预测本地 UI
Burning 是否生效及其周期目标的服务器运行时状态播放预测或确认后的表现
本次施法正在等待什么各端对应的能力运行时本地可先响应,服务器决定是否成立
直接伤害 120 的最终结算服务器规则层根据结果更新本地复制状态
动画、粒子、音效和镜头每个客户端的表现层允许预测,但需接受校正

注意:“每个客户端都有一份”不等于“每份都能修改事实”。客户端可以拥有本地副本,但副本的更新权限、预测范围和校正方式必须明确。

4.3 网络请求会放大脚本耦合#

如果仍然由 FireballSkill : MonoBehaviour 负责全部事情,它现在还要处理:

本地输入
-> 本地检查
-> 本地预测
-> 发送请求
-> 等待服务器结果
-> 接受、拒绝或回滚
-> 处理重复和乱序
-> 清理能力运行时

这时最重要的抽象不是“让客户端和服务器各有一个 FireballSkill”,而是让两端复用同一套可测试的规则语义,把输入意图、运行时状态、服务器结果和表现事件分开。

5. 传统 Skill MonoBehaviour 反例#

下面把前面的需求故意重新塞回一个脚本:

示例代码EnemyAnimatorSendCastRequestToServerApplyBurning 的具体实现未展示。这里的重点是职责混合,不是提供可直接运行的 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 这样的语义。可以用下面的判断标准做技术评估:

信号说明
多个技能共享资源、冷却或阻断规则同一类逻辑开始在多个脚本复制
有持续、周期、叠加或可撤销状态状态需要统一生命周期,而不是各自写计时器
技能存在读条、目标确认或命中等待能力已经不是单帧函数,需要可取消流程
角色会被眩晕、沉默、死亡或硬直打断入口判断不足以管理中间态
客户端与服务器都要执行相关规则需要明确哪些是预测,哪些是权威
需要在不启动场景时测试规则表现和战斗逻辑必须分离
设计师或策划会持续组合新的效果静态定义与运行时实例需要分开

一个实用的分级方式是:

  1. 只有单机、技能很少、没有共享状态:继续使用轻量脚本没有问题,但仍应避免让表现组件拥有角色属性。
  2. 出现共享资源、Buff、打断或大量技能:优先引入 Tag、Effect 生命周期和独立的能力运行时,不必一次实现完整 GAS。
  3. 需要多人战斗、客户端预测、服务器拒绝和状态回滚:至少需要明确的能力流程、属性存储、效果实例、事件/表现边界和权威协议。

通用原则:采用 GAS 风格架构的理由,应当是它能降低项目已经出现的复杂度,而不是因为类名听起来更专业。

01-从技能脚本到能力系统
https://blog.rouming-fei.top/posts/01-from-skill-scripts-to-ability-systems/
作者
废江流
发布于
2026-07-24
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
废江流
提灯寻影,灯到影灭。
分类
标签
站点统计
文章
15
分类
4
标签
18
总字数
37,248
运行时长
0
最后活动
0 天前

文章目录