《Cocos 游戏开发入门》14.2 技能与效果系统:数据驱动

技能不该是一坨 if-else。本节为 mini2d 拆出「效果」原子——伤害、加速、无敌、吸附——用生命周期钩子与叠加规则把它们组合成技能,再用一份 JSON 配置表驱动,让策划改数值不必改代码;末节对照 Cocos Creator 3.8 的 JsonAsset 与 resources.load 加载配置。

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 经济与掉落表 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画