14.2 技能与效果系统:数据驱动
14.1 我们把结算收敛成了一条管线,伤害、治疗、死亡、掉落都有明确的顺序与边界。但《方块冲刺》的技能还是一团乱麻:冲刺、护盾、磁铁吸附、加速鞋,每个技能的数值、持续时间、触发条件都硬编码在各自的脚本里。策划想把冲刺的无敌时间从 0.4 秒改成 0.5 秒,就得来找程序改一行常量、重新打包。这一节把技能拆成效果原子,用配置表驱动,让数值改动停留在数据层。末节给出 Cocos Creator 3.8 的对照。
本节的目标是「策划改数值不改代码」:所有技能的参数都在一份 JSON 里,程序只负责把效果原子拼装起来。这是把 14.1 的结算管线真正用起来的场景。
14.2.1 硬编码技能的维护地狱
先看直觉写法的代价:
// 反面教材:一个技能一个分支
function castSkill(id: string, player: Player): void {
if (id === 'dash') {
player.invincible = 0.4;
player.speedMul = 1.6;
setTimeout(() => { player.speedMul = 1; }, 800);
} else if (id === 'shield') {
player.shield = 30;
player.invincible = 1.0;
} else if (id === 'magnet') {
player.magnetRadius = 120;
// ... 忘了在什么时候关掉
}
}
这段代码有四个隐患,且都不是「写得丑」这么简单:
| 隐患 | 后果 |
|---|---|
setTimeout 计时 | 与游戏时钟脱钩,暂停时技能照常到期 |
| 参数写死在分支里 | 改数值要改代码、重新打包、重新发版 |
| 效果的「结束」逻辑散落 | 磁铁忘了关,玩家永久吸附 |
| 新增技能就要改函数 | castSkill 无限膨胀,冲突越来越难查 |
根子在于把「技能」当成了一个不可再分的整体。而真实技能本质上是若干效果的组合:冲刺 = 加速 + 短暂无敌;护盾 = 一次性吸收 + 持续无敌;磁铁 = 范围吸附。只要把效果拆成可复用的原子,技能就变成了「原子 + 参数 + 时长」的列表。
14.2.2 效果原子:四种基本效果
《方块冲刺》里足够用的四种原子,覆盖了绝大多数技能:
export interface EffectContext {
readonly caster: number;
readonly target: number;
readonly power: number;
}
export interface Effect {
readonly type: string;
/** 生效瞬间:一次性效果在这里完成 */
apply?(ctx: EffectContext, world: EffectWorld): void;
/** 每帧推进:持续型效果在这里累计时间、修改状态 */
tick?(ctx: EffectContext, world: EffectWorld, dt: number): void;
/** 到期或被打断:必须在这里把状态改回去 */
expire?(ctx: EffectContext, world: EffectWorld): void;
}
四个具体原子,各自的 apply / tick / expire 分工不同:
export function damageEffect(amount: number): Effect {
return {
type: 'damage',
apply: (ctx, world) => { world.enqueueDamage(ctx.caster, ctx.target, amount); },
};
}
export function hasteEffect(mul: number, duration: number): Effect {
return {
type: 'haste',
apply: (ctx, world) => { world.mulSpeed(ctx.target, mul); },
tick: (_ctx, _world, dt) => { void dt; },
expire: (ctx, world) => { world.mulSpeed(ctx.target, 1 / mul); },
};
}
export function invincibleEffect(duration: number): Effect {
return {
type: 'invincible',
apply: (ctx, world) => { world.setInvincible(ctx.target, true); },
expire: (ctx, world) => { world.setInvincible(ctx.target, false); },
};
}
export function magnetEffect(radius: number): Effect {
return {
type: 'magnet',
apply: (ctx, world) => { world.setMagnet(ctx.target, radius); },
expire: (ctx, world) => { world.setMagnet(ctx.target, 0); },
};
}
关键纪律:凡是在 apply 里改了状态的,expire 里必须改回去。加速用「乘 mul」而不是「设为 mul」,到期时乘 1 / mul 抵消,这样多个加速可以叠加而不会互相覆盖。这条对称性是把效果做成原子的前提——只要有一个原子破坏了它,整个系统就会留下「改不回去」的状态。
14.2.3 效果的实例与生命周期
原子是工厂函数,每次施放都要生成一个带独立计时器的实例,否则两个同类型效果会共用同一个剩余时间:
export interface ActiveEffect {
readonly effect: Effect;
readonly ctx: EffectContext;
remaining: number;
stacks: number;
}
export class EffectSystem {
private active = new Map<number, ActiveEffect[]>();
add(ctx: EffectContext, effect: Effect, duration: number): void {
const list = this.active.get(ctx.target) ?? [];
list.push({ effect, ctx, remaining: duration, stacks: 1 });
this.active.set(ctx.target, list);
effect.apply?.(ctx, this.world);
}
update(dt: number): void {
for (const [target, list] of this.active) {
for (let i = list.length - 1; i >= 0; i--) {
const ae = list[i];
ae.effect.tick?.(ae.ctx, this.world, dt);
ae.remaining -= dt;
if (ae.remaining <= 0) {
ae.effect.expire?.(ae.ctx, this.world);
list.splice(i, 1);
}
}
if (list.length === 0) this.active.delete(target);
}
}
}
三个实现细节值得强调。第一,remaining 用游戏 dt 递减,不用 setTimeout——暂停时 dt 为 0,技能自动冻结,恢复后继续,这正是第 4 章 timeScale 的应用。第二,倒序遍历 list 并用 splice(i, 1) 删除,避免正序遍历时索引错位。第三,实例自己持有 ctx,tick / expire 拿到的永远是施放时的目标,即便施法者已经死亡。
14.2.4 叠加规则:同类型效果相遇怎么办
两个加速、三个护盾、连续两次无敌——同类型效果叠加时行为必须明确,不能靠「碰巧」。用一组字符串常量表达四种策略(这里不用 enum,遵守 erasable syntax 约束):
export type Stacking = 'stack' | 'refresh' | 'ignore' | 'extend';
export function resolveStacking(existing: ActiveEffect, incoming: ActiveEffect, rule: Stacking): boolean {
switch (rule) {
case 'stack':
existing.stacks += 1;
return true;
case 'refresh':
existing.remaining = Math.max(existing.remaining, incoming.remaining);
return false;
case 'extend':
existing.remaining += incoming.remaining;
return false;
case 'ignore':
return false;
}
}
四种策略的语义与适用场景:
| 规则 | 行为 | 典型用途 |
|---|---|---|
stack | 叠加层数,效果按层数放大 | 可叠的毒、增伤 buff |
refresh | 只延长到较长的那个时长 | 加速、无敌(不允许无限续) |
extend | 时长直接相加 | 某些可续的增益 |
ignore | 已有则完全忽略新的 | 唯一性光环、护盾 |
《方块冲刺》的约定是:增益默认 refresh,减益默认 stack。这个默认值写在一处,新增技能时不用每次重复声明。要注意 refresh 用 Math.max 而不是直接覆盖——如果新效果的剩余时间比旧的短,覆盖反而会缩短现有 buff,是常见的手感 bug。
14.2.5 技能 = 效果序列 + 配置表
有了原子和叠加规则,技能就退化成一份纯数据。配置表是 JSON,策划直接改:
{
"skills": [
{
"id": "dash",
"name": "冲刺",
"cooldown": 3.0,
"effects": [
{ "type": "haste", "mul": 1.6, "duration": 0.8, "stacking": "refresh" },
{ "type": "invincible", "duration": 0.4, "stacking": "ignore" }
]
},
{
"id": "magnet",
"name": "磁铁",
"cooldown": 8.0,
"effects": [
{ "type": "magnet", "radius": 120, "duration": 5.0, "stacking": "ignore" }
]
}
]
}
程序侧只做三件事:解析配置、按 type 查工厂、把参数喂进去。工厂注册表把「字符串」映射到「构造函数」:
export interface EffectParams {
amount?: number;
mul?: number;
duration?: number;
radius?: number;
}
export interface SkillEffectConfig extends EffectParams {
type: string;
stacking?: Stacking;
}
export interface SkillConfig {
id: string;
name: string;
cooldown: number;
effects: SkillEffectConfig[];
}
export const EFFECT_FACTORY: Record<string, (p: EffectParams) => Effect> = {
damage: (p) => damageEffect(p.amount ?? 0),
haste: (p) => hasteEffect(p.mul ?? 1, p.duration ?? 0),
invincible: (p) => invincibleEffect(p.duration ?? 0),
magnet: (p) => magnetEffect(p.radius ?? 0),
};
export function castSkill(cfg: SkillConfig, ctx: EffectContext, system: EffectSystem): void {
for (const entry of cfg.effects) {
const factory = EFFECT_FACTORY[entry.type];
if (!factory) {
console.warn(`[mini2d] 未知效果类型:${entry.type}`);
continue;
}
system.add(ctx, factory(entry), entry.duration ?? 0);
}
}
新增一个效果类型,只需要「写一个工厂 + 在注册表加一行 + 配置表里引用」,castSkill 一个字都不用动。这就是数据驱动的全部意义:把变化点从代码挪到数据。未知类型打印告警并跳过而不是抛异常,是为了让策划在配置里试验新字段时不会把游戏直接搞崩。
14.2.6 Cocos 对照:JsonAsset 与 resources.load
在 Cocos Creator 3.8 里,配置表通常放在 assets/resources/config/ 下,运行时用 resources.load 加载成 JsonAsset(cc.d.ts 中 export class JsonAsset extends Asset,字段 json: Record<string, any> | null 就是解析后的对象)。也可以直接在编辑器里把资源拖到组件的 @property 上:
import { _decorator, Component, JsonAsset, resources } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('SkillTable')
export class SkillTable extends Component {
@property({ type: JsonAsset, tooltip: '技能配置表,可在编辑器里直接指定' })
skillAsset: JsonAsset | null = null;
private skills: SkillConfig[] = [];
start(): void {
if (this.skillAsset) {
this.parse(this.skillAsset.json);
return;
}
// 未在编辑器指定时,从 resources 目录按路径加载
resources.load('config/skills', JsonAsset, (err, asset) => {
if (err) {
console.error('[方块冲刺] 技能表加载失败', err);
return;
}
this.parse(asset.json);
});
}
private parse(raw: Record<string, any> | null): void {
if (!raw) return;
this.skills = raw['skills'] as SkillConfig[];
}
}
两种加载方式的取舍:@property 指定适合少量、必须在场景加载前就绪的配置,编辑器里能直接看到引用关系,加载是同步的;resources.load 适合配置多、想按需加载或热更的场景,但它是异步的,且 resources 目录下所有资源都会被打进主包,配置表小则无所谓,资源多时要改用 Asset Bundle 分包。无论哪种,JsonAsset.json 拿到的是已解析的对象,不需要再 JSON.parse。
| 关注点 | mini2d 效果系统 | Cocos Creator 3.8 |
|---|---|---|
| 配置载体 | JSON 文件 + 自写解析 | JsonAsset(.json 资源) |
| 加载方式 | import / fetch | @property 或 resources.load |
| 效果组合 | EFFECT_FACTORY 注册表 | 同样可用注册表 / 策略模式 |
| 计时 | 游戏 dt 递减 | update(dt) 递减(同源) |
| 叠加规则 | Stacking 字符串常量 | 同左,纯逻辑与引擎无关 |
注意最后一行的分量:效果系统的核心逻辑与引擎完全无关。EffectSystem、EFFECT_FACTORY、Stacking 这些代码原封不动就能搬进 Cocos 工程,因为它们的输入是 dt 与 World,不依赖任何渲染 API。这也正是我们把纯逻辑写在 mini2d 里的价值——它是可移植的。下一节我们把技能与经济打通:击杀掉落什么、掉多少、要不要保底,全靠一张掉落表。
阅读导航:上一节:14.1 结算管线与帧对齐 · 下一节:14.3 经济与掉落表 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。