Buff 系统失控通常不是从大需求开始的
Roguelike 项目里,Buff 往往从几个简单效果开始:攻击力加 10%,移动速度加 20%,击杀后回血。过一周,策划想要“暴击后召唤闪电”“每第三次攻击附带毒”“护盾存在时子弹变大”“低血量时所有火焰伤害翻倍”。如果最初只是给角色对象塞几个布尔值,很快就会出现无法解释的结果:同样两个道具,拾取顺序不同伤害不同;UI 显示加成 50%,实际计算是 43%;一个 Buff 到期后把另一个 Buff 的数值也清掉。玩家喜欢复杂组合,但前提是系统行为稳定、可解释、可复现。
Phaser 负责表现层,Buff 系统应该是独立的战斗模型。Sprite 播放火焰特效,不代表火焰伤害应该写在 Sprite 上。一个好的 Buff 系统至少要回答四个问题:属性如何叠加,事件如何触发,持续时间如何推进,UI 如何解释。本文以一个俯视角 Roguelike 射击游戏为例,拆解 Buff 架构、叠加规则和调试方式。
先区分属性修饰和事件效果
Buff 大体可以分为两类。第一类是属性修饰,比如攻击力、攻速、移速、护甲、暴击率。这类效果参与数值计算,可以按加法、乘法、覆盖、最大值等规则叠加。第二类是事件效果,比如命中时燃烧、击杀时回血、受伤时反弹子弹。这类效果监听战斗事件,在特定时机执行。把两类混在一起会很痛苦,因为属性修饰需要稳定排序,事件效果需要生命周期和触发限制。
建议每个 Buff 配置包含:id、名称、层数上限、持续时间、属性修饰列表、事件监听列表、互斥标签、显示优先级。运行时 Buff 实例只保存来源、层数、剩余时间和内部计数器。不要把复杂函数直接写进 JSON;配置可以引用效果 id,代码中注册效果处理器。这样既能让策划配置大部分内容,也能保持类型安全。
flowchart TD
A["拾取道具或技能升级"] --> B["BuffRegistry 查询配置"]
B --> C["BuffContainer 添加或叠层"]
C --> D["StatResolver 计算最终属性"]
C --> E["EventBus 注册触发效果"]
F["战斗事件:命中、击杀、受伤、回合结束"] --> E
E --> G["EffectExecutor 生成伤害、治疗、召唤物"]
D --> H["角色控制、武器发射、UI 面板"]
G --> I["Phaser 表现:粒子、音效、浮字"]
属性叠加要有固定顺序
属性计算最怕“看当前代码执行到哪里”。一个可靠顺序可以是:基础值、固定加成、百分比加成、乘法修正、上下限夹取、临时覆盖。比如攻击力基础 100,装备加 20,两个 Buff 各加 10%,一个诅咒乘以 0.8,最终是 (100 + 20) * (1 + 0.1 + 0.1) * 0.8 = 115.2。如果每个 Buff 自己随便改 player.attack,顺序就不可控,移除时也不知道恢复到哪里。
StatResolver 应该每次从基础属性和当前 Buff 重新计算,而不是在添加 Buff 时增量修改角色属性。重算看似浪费,但角色数量通常有限,且能避免移除错误。对于大量敌人,可以缓存并在 Buff 变化时标记 dirty。UI 面板也应该使用同一个 Resolver,保证展示和实战一致。很多数值争议来自 UI 用一套算法,战斗用另一套算法。
事件触发要定义时机
Roguelike 的事件链很容易嵌套。一次子弹命中触发暴击,暴击触发闪电,闪电击杀敌人,击杀触发回血,回血又触发“过量治疗变护盾”。如果没有事件阶段和递归限制,系统可能无限循环。建议把战斗事件分为明确阶段:命中前、伤害计算前、伤害应用后、击杀确认后、结算后。每个效果只能在声明的阶段执行,并且事件上下文要只暴露允许修改的字段。
同时要限制触发频率。比如“命中时释放火球”每 0.5 秒最多一次;“击杀后爆炸”不能由爆炸产生的召唤物再次无限触发;“受伤反弹”不能反弹反弹伤害。可以在事件上下文中带上 tags 和 depth,效果处理器根据标签决定是否忽略。不要把这些保护写成临时 if,否则组合越多越难维护。
一个可用的 Buff 容器
下面的代码展示了基础结构。它没有包含所有效果,但体现了核心思想:BuffContainer 管生命周期,StatResolver 管属性,事件效果通过注册表执行。
type ModifierOp = "add" | "percent" | "multiply";
type StatKey = "attack" | "moveSpeed" | "critRate";
interface StatModifier {
stat: StatKey;
op: ModifierOp;
value: number;
}
interface BuffConfig {
id: string;
maxStacks: number;
durationMs?: number;
modifiers: StatModifier[];
}
interface BuffInstance {
config: BuffConfig;
stacks: number;
remainingMs?: number;
}
export class BuffContainer {
private buffs = new Map<string, BuffInstance>();
add(config: BuffConfig) {
const current = this.buffs.get(config.id);
if (current) {
current.stacks = Math.min(config.maxStacks, current.stacks + 1);
current.remainingMs = config.durationMs ?? current.remainingMs;
return;
}
this.buffs.set(config.id, {
config,
stacks: 1,
remainingMs: config.durationMs,
});
}
tick(deltaMs: number) {
for (const [id, buff] of this.buffs) {
if (buff.remainingMs === undefined) continue;
buff.remainingMs -= deltaMs;
if (buff.remainingMs <= 0) this.buffs.delete(id);
}
}
modifiers() {
return [...this.buffs.values()].flatMap((buff) =>
buff.config.modifiers.map((mod) => ({ ...mod, value: mod.value * buff.stacks })),
);
}
}
这里有一个容易忽略的点:叠层时持续时间如何处理。是刷新到满时长,还是追加时长,还是每层独立计时?三种都合理,但必须按 Buff 配置明确。毒层可能每层独立计时,狂暴可能叠层刷新,护盾可能追加数值但不刷新。不要让所有 Buff 使用同一种规则,否则后期会被设计需求逼到处开特例。
UI 展示要解释组合,而不是只堆图标
Roguelike 玩家喜欢研究组合,所以 Buff UI 不能只显示一排小图标。鼠标悬停或长按时,应该展示层数、剩余时间、来源、叠加规则和当前贡献。比如“燃烧强化:火焰伤害 +15%,当前 3 层,共 +45%,剩余 8.2 秒”。如果有互斥或覆盖,也要写清楚。玩家可以接受复杂,但不能接受隐藏规则。
战斗中图标数量可能很多。可以按显示优先级和类型分组:危险状态、核心构筑、临时增益、后台被动。短时间、高频刷新的 Buff 不一定都显示大图标,可以用角色脚下环、屏幕边缘提示或武器颜色变化表达。Phaser UI 如果使用 DOM Overlay,要注意缩放和移动端触摸;如果用纯 Canvas,要做好文本换行和图标池。
调试面板比特效更值钱
Buff bug 很难靠肉眼发现,因为结果通常是“伤害好像不对”。开发模式需要一个面板显示当前 Buff 列表、最终属性拆解和最近事件链。点击一次攻击,可以看到基础伤害、固定加成、百分比加成、暴击、元素修正、最终伤害。事件链可以显示“bulletHit -> beforeDamage -> applyDamage -> enemyKilled -> healOnKill”。这类工具会占用一天开发时间,但能节省后面数周争吵。
还可以保存战斗快照。玩家反馈某个组合无限回血时,你需要知道他有哪些道具、Buff 层数、敌人状态、随机种子和最近事件。没有快照只能靠猜。Roguelike 的魅力来自组合爆炸,工程上就要接受组合爆炸带来的可观测性需求。
数值安全边界
Buff 系统一定要有上限。暴击率不能超过配置允许值,攻速不能快到每帧发射 20 发子弹,移动速度不能穿墙,伤害倍率不能溢出。上限不一定要在 UI 上强调,但 Resolver 必须夹取。另一个边界是浮点误差。大量百分比叠加后,小数位可能让 UI 和实际伤害有差异。建议在最终应用伤害时统一取整规则,比如向下取整但至少 1 点,UI 也使用同样规则。
如果有联机、排行榜或付费内容,客户端 Buff 只能做预测和表现,关键结算要能服务端复算。即使是单机,也要把配置版本写入存档。更新后某个 Buff 从乘法改为加法,旧存档可能需要迁移,否则玩家打开游戏发现构筑突然变弱,会很难解释。
上线前检查清单
确认属性修饰和事件效果分开建模;确认属性叠加顺序固定并有测试;确认每种 Buff 的叠层和刷新规则写在配置里;确认事件触发有阶段、标签和递归限制;确认 UI 展示层数、剩余时间和贡献值;确认移除 Buff 后属性从基础值重算;确认调试面板能展示最终属性拆解和事件链;确认极端组合不会突破攻速、移速、暴击率和伤害上限;确认存档记录 Buff 配置版本;确认 Phaser 表现层不直接决定战斗数值。
Buff 系统做得好,玩家会觉得自己在发现构筑;做得差,玩家会觉得自己在和 bug 合作。Phaser 可以把效果表现得很热闹,但真正支撑 Roguelike 深度的是规则的稳定性。先让每一次叠加都能解释,再去加更夸张的雷电和火焰。
内容生产要给策划留安全护栏
Buff 系统一旦开放配置,策划会很快产出几十上百个词条。工程上不能只提供字段,还要提供校验。比如百分比值是否超出合理范围,持续时间是否为负,互斥标签是否存在,事件效果是否引用了已注册处理器,描述文本中的数值是否和配置一致。这些检查最好在启动时和内容导出时都跑一遍。不要让一个拼错的效果 id 到战斗中才静默失效。
另一个很实用的工具是构筑沙盒。开发模式下可以一键给玩家添加指定 Buff 组合,生成 30 秒战斗样本,输出平均伤害、峰值伤害、触发次数和帧率压力。Roguelike 的坏组合不一定是数值最强,有时是特效过多导致掉帧,有时是事件递归让日志爆炸。把这些问题放进工具里,比靠人肉刷道具快得多。Phaser 项目通常迭代快,更需要这种轻量但直接的内容验证。
如果 Buff 会影响掉落或商店刷新,还要把随机种子和权重变化写进日志。否则玩家反馈“拿到某个遗物后再也刷不出治疗”时,团队只能猜是概率、配置还是错觉。可解释的随机,比看起来神秘的随机更适合长期运营。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。