《Cocos 游戏开发实战》8.2 数值表加载与校验

8.1 定了 levels.json 的结构,但表是人写的,人一定会写错。本节写加载与校验两层:用 assetManager.loadBundle 与 JsonAsset 从 bundle-levels 读表并做缓存与释放,再用一份纯 TS 校验器把漏字段、y 越界、goalX 早于最后障碍等错误在加载时就变成清晰报错,配 vitest 用例与失败兜底。

8.2 数值表加载与校验

8.1 把关卡从场景搬进了 levels.json,但只解决了一半问题:表是人写的,人一定会写错。漏一个 speed、把 y 写成字符串 "-120"、goalX 比最后一个障碍还小——这些错误不会让程序崩溃,只会让方块在某个位置凭空消失,或者关卡永远走不到终点。这类 bug 的调试成本极高,因为症状与原因隔着好几层。

这一节补上另一半:加载与校验。目标是把「表坏了」在加载的那一瞬间变成一条能读懂的错误,而不是留到三十分钟后的玩家反馈里。

8.2.1 加载路径:从 Bundle 读表

8.1 把 assets/configs/ 配成了 Bundle bundle-levels,所以加载要走 assetManager.loadBundle,而不是 resources.load:

// assets/scripts/game/level-loader.ts
import { _decorator, Component, JsonAsset, AssetManager, assetManager, error } from 'cc';
const { ccclass, property } = _decorator;

const BUNDLE = 'bundle-levels';

@ccclass('LevelLoader')
export class LevelLoader extends Component {
  @property({ tooltip: '关卡表在 Bundle 内的路径,不含扩展名' })
  tablePath = 'levels';

  private bundle: AssetManager.Bundle | null = null;
  private cached: JsonAsset | null = null;

  loadTable(cb: (raw: unknown, err: Error | null) => void): void {
    if (this.cached) { cb(this.cached.json, null); return; }
    if (this.bundle) { this.readFrom(this.bundle, cb); return; }
    assetManager.loadBundle(BUNDLE, (err: Error | null, bundle: AssetManager.Bundle) => {
      if (err) { error(`加载分包 ${BUNDLE} 失败: ${err.message}`); cb(null, err); return; }
      this.bundle = bundle;
      this.readFrom(bundle, cb);
    });
  }

  private readFrom(bundle: AssetManager.Bundle, cb: (raw: unknown, err: Error | null) => void): void {
    bundle.load(this.tablePath, JsonAsset, (err: Error | null, asset: JsonAsset) => {
      if (err) { error(`读取 ${this.tablePath} 失败: ${err.message}`); cb(null, err); return; }
      this.cached = asset;
      cb(asset.json, null);
    });
  }

  releaseTable(): void {
    if (!this.bundle || !this.cached) return;
    this.bundle.release(this.tablePath, JsonAsset);
    this.cached = null;
  }
}

四个决定值得说明。第一,tablePath 是 'levels' 而不是 'levels.json'——Cocos 的资源路径不带扩展名,写成带 .json 会加载失败,这是从文件系统思维切到引擎资源思维时最常犯的错。第二,Bundle 句柄与 JsonAsset 都缓存起来,避免每次进关都重新走一遍下载与解析。第三,releaseTable 在切场景时调用,bundle.release 会减引用计数(第 4 章讲过引用计数的语义)。第四,所有错误都先 error() 打日志再回调,因为加载失败时调用方往往直接 return,不打日志就彻底静默了。

8.2.2 校验器:让「表坏了」在加载时爆炸

校验逻辑不依赖引擎,放在 core/ 下,可以直接单测:

// assets/scripts/core/validate-level.ts
import type { LevelDef, LevelTable, ObstacleDef } from './level-def';

export interface ValidationIssue { path: string; message: string; }
export interface ValidationResult { ok: boolean; issues: ValidationIssue[]; }

/** 关卡 y 的可用区间:地面线附近,允许浮空障碍但不允许埋进地里。 */
const Y_MIN = -200;
const Y_MAX = 400;

export function validateLevelTable(raw: unknown): ValidationResult {
  const issues: ValidationIssue[] = [];
  if (typeof raw !== 'object' || raw === null) {
    return { ok: false, issues: [{ path: '$', message: '根节点不是对象' }] };
  }
  const table = raw as Partial<LevelTable>;
  if (table.version !== 1) {
    issues.push({ path: 'version', message: `不支持的版本 ${String(table.version)},期望 1` });
  }
  if (!Array.isArray(table.levels) || table.levels.length === 0) {
    issues.push({ path: 'levels', message: 'levels 必须是非空数组' });
    return { ok: false, issues };
  }

  table.levels.forEach((lv: LevelDef, i: number) => {
    const base = `levels[${i}]`;
    if (typeof lv.id !== 'string' || lv.id.length === 0) {
      issues.push({ path: `${base}.id`, message: 'id 缺失或为空' });
    }
    if (!(lv.speed > 0)) issues.push({ path: `${base}.speed`, message: 'speed 必须大于 0' });
    if (!(lv.goalX > 0)) issues.push({ path: `${base}.goalX`, message: 'goalX 必须大于 0' });

    const obstacles: ObstacleDef[] = Array.isArray(lv.obstacles) ? lv.obstacles : [];
    obstacles.forEach((ob, j) => {
      const p = `${base}.obstacles[${j}]`;
      if (!(ob.w > 0) || !(ob.h > 0)) issues.push({ path: p, message: 'w 与 h 必须为正数' });
      if (ob.y < Y_MIN || ob.y > Y_MAX) {
        issues.push({ path: `${p}.y`, message: `y=${ob.y} 超出可用区间 [${Y_MIN}, ${Y_MAX}]` });
      }
    });

    const lastX = obstacles.reduce((m, ob) => Math.max(m, ob.x), 0);
    if (lastX >= lv.goalX) {
      issues.push({ path: `${base}.goalX`, message: `终点 ${lv.goalX} 早于最后一个障碍 ${lastX}` });
    }
  });

  return { ok: issues.length === 0, issues };
}

这个校验器有三层,缺一不可:

  1. 结构层:根是不是对象、levels 是不是非空数组。这一层失败就直接返回,后面的遍历没意义。
  2. 字段层:逐个检查必填字段的类型与取值。注意 !(lv.speed > 0) 的写法——它能同时挡住 undefined、0、NaN 与负数,比 lv.speed <= 0 更严(NaN <= 0 是 false,会漏掉 NaN)。
  3. 关系层:goalX 必须大于最后一个障碍的 x。这是跨字段约束,单看每个字段都合法,只有放在一起才暴露矛盾——也是最容易被漏掉的一层。

校验器只报告不修复。看到「y 越界」就自动夹取到边界内,是最危险的做法:它把错误藏起来了,策划改完表以为生效了,实际跑的是被夹取过的值。宁可加载失败,也不要静默修正。

8.2.3 校验规则清单

把上面写死的规则整理成一张表,方便后面加规则时对照:

规则层级报错示例
根节点是对象结构$: 根节点不是对象
version === 1结构version: 不支持的版本 2,期望 1
levels 非空数组结构levels: levels 必须是非空数组
id 非空字符串字段levels[0].id: id 缺失或为空
speed > 0字段levels[0].speed: speed 必须大于 0
goalX > 0字段levels[0].goalX: goalX 必须大于 0
w > 0 && h > 0字段levels[1].obstacles[2]: w 与 h 必须为正数
-200 ≤ y ≤ 400字段levels[0].obstacles[0].y: y=-260 超出可用区间
goalX > max(obstacle.x)关系levels[1].goalX: 终点 3200 早于最后一个障碍 3600

path 的写法是刻意的:levels[1].obstacles[2].y 这种路径能让人一眼定位到表里的哪一行。如果只报「y 越界」,在一个 50 关的表里等于没报。

8.2.4 用 vitest 钉住校验行为

校验器是纯函数,最适合单测。把 8.2.3 的每一行规则写成一个用例,以后加规则时不会碰坏旧规则:

// assets/scripts/core/validate-level.test.ts
import { describe, it, expect } from 'vitest';
import { validateLevelTable } from './validate-level';

const base = {
  version: 1,
  levels: [{
    id: 'level-01', name: 'T', speed: 300, goalX: 3600,
    obstacles: [{ type: 'block', x: 900, y: -120, w: 200, h: 40 }],
    coins: [{ x: 700, y: 40 }],
  }],
};

describe('validateLevelTable', () => {
  it('合法表通过', () => {
    expect(validateLevelTable(base).ok).toBe(true);
  });

  it('goalX 早于最后障碍时报关系错误', () => {
    const bad = structuredClone(base);
    bad.levels[0].goalX = 800;
    const r = validateLevelTable(bad);
    expect(r.ok).toBe(false);
    expect(r.issues.some((i) => i.path.endsWith('.goalX'))).toBe(true);
  });

  it('y 越界时报字段错误', () => {
    const bad = structuredClone(base);
    bad.levels[0].obstacles[0].y = -260;
    const r = validateLevelTable(bad);
    expect(r.ok).toBe(false);
  });

  it('levels 为空数组直接失败', () => {
    expect(validateLevelTable({ version: 1, levels: [] }).ok).toBe(false);
  });
});

跑 npx vitest run 时这四个用例把「结构层失败要短路」「关系层必须查」这两条行为固定下来。第 16 章会把它们接进 CI。

8.2.5 加载时序与失败兜底

把加载器、校验器与游戏流程串起来的是 LevelService。它只做一件事:校验通过才落盘,否则保持未就绪,让上层能判断当前能不能开一局:

// assets/scripts/game/level-service.ts
import { _decorator, Component, Node } from 'cc';
const { ccclass, property } = _decorator;

/** 与 core/validate-level.ts 的 ValidationResult 结构一致。 */
interface Issue { path: string; message: string; }
interface Result { ok: boolean; issues: Issue[]; }

@ccclass('LevelService')
export class LevelService extends Component {
  @property({ type: Node, tooltip: '关卡挂载根节点' })
  levelRoot: Node | null = null;

  private raw: unknown = null;

  /** validator 由 core/validate-level.ts 注入,便于单测与替换。 */
  accept(raw: unknown, validator: (v: unknown) => Result): boolean {
    const result = validator(raw);
    if (!result.ok) {
      for (const issue of result.issues) {
        console.error(`[levels] ${issue.path}: ${issue.message}`);
      }
      return false;
    }
    this.raw = raw;
    return true;
  }

  get ready(): boolean { return this.raw !== null; }
  get table(): unknown { return this.raw; }
}

把校验器作为参数注入而不是直接 import,是为了让 LevelService 能在单测里换成一个永远失败的桩,专门验证「校验失败时 ready 保持 false」。上线时的兜底策略是:加载或校验失败时,用一个内建的 level-01 硬编码数据开一局,让玩家至少能玩到,同时把错误上报(第 16 章)。绝不能因为一张表坏了就把玩家卡在加载页——加载页是最贵的页面,没有之一。

8.2.6 构建面板里的 bundle-levels

这一小节属于 L3:需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。 打开「项目 → 构建发布」,平台选「微信小游戏」,在「资源分包」区块里会看到两行:

项取值
主包压缩类型Merge Depend
首包main(含启动场景与主 UI)
分包bundle-levels(含 levels.json 与关卡预制体)
MD5 Cache勾选
Source Maps发布版不勾,调试版勾
远程服务器地址首期留空,关卡表随包发

如果这里没有出现 bundle-levels,说明 8.1 里「配置为 Bundle」那一步没生效,或者 bundleName 拼错了。这是本节最容易验证、也最容易被忽略的一步:配置对不对,构建面板会直接告诉你。

8.2.7 收口

到这一节,关卡数据的链路完整了:levels.json 放在 bundle-levels 分包里,LevelLoader 负责取、validateLevelTable 负责查、LevelService 负责守门。工程里多了 game/level-loader.ts、game/level-service.ts、core/validate-level.ts 与 core/validate-level.test.ts。

但表还是人写的,写一关要摆二十个障碍、算一遍间距。下一节反过来:让程序按难度曲线生成关卡,人只提供种子与参数。这也是数据驱动真正发挥威力的地方——生成出来的表同样要过 8.2 的校验器,因为生成器也会写错。

阅读导航:上一节:8.1 关卡编辑与配置表 · 下一节:8.3 程序化关卡与难度曲线 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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