《Cocos 游戏开发实战》8.1 关卡编辑与配置表

上一章把玩法跑通,但关卡还是场景里手工摆的节点,加一关就要复制一份场景。本节把整关的摆放变成数据:定义 levels.json 的 schema(obstacles/coins/goalX/speed),讲清关卡局部坐标的取值口径,再把 assets/configs 配成 Bundle bundle-levels,并给出 Obstacle.prefab 与命名约定。

8.1 关卡编辑与配置表

第 7 章结束时,玩法闭环了,但关卡本身还是「在场景里手工摆节点」:Ground、Coins、Hazards 都是一个个拖出来的实例。想加一关,复制一份 main.scene;想改一处沟宽,点开节点改 UITransform。十关之后,工程里会有十份几乎一样的场景文件,而美术改一次地面贴图就要改十遍。

本章的主题是数据驱动:把整关的摆放写进一份 JSON,让代码在运行时把场景搭出来。本节先把数据结构与编辑器配置定下来。

8.1.1 两条路:场景摆放还是配置表

先说清楚为什么选配置表。两种做法各有明确的适用面:

维度场景手工摆放配置表驱动
单关改动的成本点开场景改节点改一行 JSON
加一关的成本复制场景 + 手工重摆表里加一个对象
批量调整(所有沟宽 +10)逐个改,易漏写脚本改表
程序化生成难天然兼容
策划能否独立改需要引擎操作能力会改 JSON 即可
调试直观度编辑器里所见即所得需要开调试绘制

《方块冲刺》是横版跑酷,关卡由可枚举的构件(平台、尖刺、金币、道具)沿一条轴排列组成,没有需要美术精修的自由构图。这类关卡天生适合配置表。反过来,如果做的是需要逐像素调整的平台跳跃或解谜关,场景摆放反而更快——不要为了统一而牺牲适用性。

本项目的取舍是:构件用配置表,视觉风格用场景。地面贴图、背景、光照这些放在场景里,摆放数据放表里。

8.1.2 定义关卡数据的 schema

配置表的第一件事是定 schema。和前面几节一样,结构定义放在 core/ 下,不依赖引擎,可以直接单测:

// assets/scripts/core/level-def.ts
export type ObstacleType = 'block' | 'spike' | 'saw';

export interface ObstacleDef {
  type: ObstacleType;
  /** 相对关卡起点的 x 坐标,像素。 */
  x: number;
  /** 相对地面线的 y 坐标,像素,向上为正。 */
  y: number;
  w: number;
  h: number;
}

export interface CoinDef {
  x: number;
  y: number;
}

export interface LevelDef {
  id: string;
  name: string;
  /** 关卡滚动速度,像素/秒。 */
  speed: number;
  /** 终点线相对起点的距离,像素。 */
  goalX: number;
  obstacles: ObstacleDef[];
  coins: CoinDef[];
}

export interface LevelTable {
  version: number;
  levels: LevelDef[];
}

这里有一个贯穿全表的设计决定:障碍用「左上角 + 宽高」还是「中心 + 宽高」。表里选了中心坐标(x、y 就是中心),因为 Cocos 的节点位置默认是锚点位置,而精灵默认锚点在中心;用中心坐标可以让「表里的数字直接写进 node.setPosition」,少一次半宽换算。卷一 mini2d 用的是中心加半宽,口径一致。

version 字段现在没用,但必须留。等到线上版本要改字段含义时,它就是唯一能救你的东西——8.2 的校验器会读它决定用哪套规则。

8.1.3 坐标口径:别让 y 轴方向出岔子

配置表最容易出错的地方不是结构,而是坐标口径。定死三条:

  1. x 从关卡起点算,不是从屏幕左边缘算。关卡起点对应 LevelRoot 的初始位置,玩家出生点在 x = 0 附近。
  2. y 以地面线为 0,向上为正。地面线的世界坐标在 960×540 的设计分辨率下取 y = -150(画布中心为原点),所以表里的 y: -120 表示「在地面线下方 120 像素」——那是实心方块;y: 40 表示「地面线上方 40 像素」——那是金币。
  3. 宽度与高度一律是像素,与设计分辨率同单位。不要混用「格」或「米」。

这三条写进 schema 注释还不够,要写进 8.2 的校验器:任何 y < -200 的障碍都会被判为「埋在地下」,直接报错。数值范围检查比注释可靠得多。

8.1.4 把 configs 配成 Bundle

assets/configs/levels.json 不能靠 resources.load 直接读到——resources 只认 assets/resources/ 目录。两种解法:把文件放进 assets/resources/,或者把目录配成 Bundle。本项目选后者,因为关卡表会随关卡数增长,进分包能压住首包体积(第 15 章展开)。

这一小节属于 L3:需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。 可照做的步骤:

  1. 资源管理器里选中 assets/configs 目录。
  2. 属性面板勾选「配置为 Bundle」,把 Bundle 名称 填成 bundle-levels。
  3. 优先级 填 1,压缩类型 选 Merge Depend,配置为远程包 不勾。

操作后,编辑器会把这个目录的 .meta 写成带 isBundle 的形式。需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。 打开 assets/configs.meta 会看到:

{
  "ver": "1.2.0",
  "importer": "directory",
  "imported": true,
  "uuid": "9b7d4c2a-1e58-4f0b-8a63-2f9c5d7e0b41",
  "files": [],
  "subMetas": {},
  "userData": {
    "isBundle": true,
    "bundleName": "bundle-levels",
    "priority": 1,
    "compressionType": {},
    "isRemoteBundle": false
  }
}

关键就三行:importer 是 directory(目录资源),userData.isBundle 为 true,bundleName 与构建面板里看到的名称一致。目录被改成 Bundle 之后,里面的所有资源不再进首包,加载方式从 resources.load 变成 assetManager.loadBundle('bundle-levels', ...)。8.2 会写完整的加载代码。

顺带一个容易踩的点:bundleName 一旦确定就不要再改。它是构建产物的目录名,也是运行时加载用的字符串;改了它,所有硬编码 'bundle-levels' 的地方都要同步改,漏一处就是运行时「找不到 Bundle」。

8.1.5 组件侧读表的入口

在编辑器里最省事的做法,是给一个组件留一个 JsonAsset 槽,直接把表拖进去——这样不依赖 Bundle,适合在编辑器里预览:

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

@ccclass('LevelIndex')
export class LevelIndex extends Component {
  @property({ type: JsonAsset, tooltip: '拖入 configs/levels.json 供编辑器内预览' })
  table: JsonAsset | null = null;

  /** 返回关卡数组;表为空时返回空数组而不是抛错。 */
  get levels(): unknown[] {
    const raw = this.table?.json as { levels?: unknown[] } | null | undefined;
    return raw?.levels ?? [];
  }

  levelAt(index: number): unknown | null {
    const list = this.levels;
    return index >= 0 && index < list.length ? list[index] : null;
  }
}

asset.json 的类型是 Record<string, any> | null,所以取 levels 前要先判空。编辑器内预览走这条路径,正式运行走 Bundle——两条路径读的是同一份 levels.json,不存在「预览和线上数据不一致」的问题。

8.1.6 一张真实的关卡表

把上面所有约定合起来,assets/configs/levels.json 大概长这样(两关节选):

{
  "version": 1,
  "levels": [
    {
      "id": "level-01",
      "name": "教学关:只跳不躲",
      "speed": 300,
      "goalX": 3600,
      "obstacles": [
        { "type": "block", "x": 900, "y": -120, "w": 200, "h": 40 },
        { "type": "block", "x": 1800, "y": -120, "w": 200, "h": 40 },
        { "type": "block", "x": 2700, "y": -80, "w": 240, "h": 40 }
      ],
      "coins": [
        { "x": 700, "y": 40 },
        { "x": 900, "y": 80 },
        { "x": 1100, "y": 40 }
      ]
    },
    {
      "id": "level-02",
      "name": "尖刺初现",
      "speed": 340,
      "goalX": 4800,
      "obstacles": [
        { "type": "spike", "x": 1000, "y": -140, "w": 40, "h": 30 },
        { "type": "spike", "x": 1060, "y": -140, "w": 40, "h": 30 },
        { "type": "block", "x": 1600, "y": -120, "w": 180, "h": 40 },
        { "type": "saw", "x": 2400, "y": -60, "w": 64, "h": 64 }
      ],
      "coins": [
        { "x": 1600, "y": 60 },
        { "x": 2400, "y": 120 }
      ]
    }
  ]
}

两关的 speed 与 goalX 都不同,这正是数据驱动的价值:难度不靠改代码,靠改表。注意 level-02 里两枚相邻尖刺的 x 差只有 60 像素,而方块宽 48 像素——这是刻意留的「必须一次跳过两枚」的间距,属于 8.3 要讨论的难度曲线设计。

8.1.7 关卡表的版本演进

表一旦上了线,就再也收不回来——玩家的客户端里装着旧版解析逻辑,服务器上放着新版的表。所以从第一天起就要把 version 当回事。三条规矩:

改动类型是否升 version迁移动作
新增可选字段(如 background)否解析时给默认值
新增必填字段是老表加载时补默认值
改字段含义(如 y 从「中心」改「底边」)是加载时按 version 换算
删除字段是保留一个版本周期再删

判断标准只有一条:旧表在新代码里能不能正确解析。能,就不升版本;不能,就必须升,并在加载时写一条 if (table.version < 2) { ... } 的迁移。8.2 的校验器会在解析前先看 version,版本高于代码支持的版本就直接拒绝加载——「表比程序新」比「表比程序旧」更危险,因为新字段可能携带程序完全不认识的语义。

8.1.8 从场景原型到表的过渡路径

完全从零手写 JSON 很累,尤其在前几关还在试探「这一跳够不够远」的时候。推荐一条过渡路径:先在场景里摆原型,定稿后导出成表。

在场景里用 Block、Spike、Coin 三类节点摆一关,节点名带序号(Coin01、Coin02)。定稿后挂一个临时导出组件,把节点树转成表:

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

/** 一次性工具:把当前节点下手工摆的构件导出成 levels.json 片段。 */
@ccclass('LevelExporter')
@executeInEditMode
export class LevelExporter extends Component {
  @property({ tooltip: '导出时使用的关卡 id' })
  levelId = 'level-01';

  exportJson(): string {
    const obstacles: Record<string, unknown>[] = [];
    const coins: Record<string, unknown>[] = [];
    for (const child of this.node.children) {
      const p = child.position;
      const item = { x: Math.round(p.x), y: Math.round(p.y), name: child.name };
      if (child.name.startsWith('Coin')) coins.push(item);
      else obstacles.push(item);
    }
    return JSON.stringify({ id: this.levelId, obstacles, coins }, null, 2);
  }
}

@executeInEditMode 让这个组件在编辑态也能跑;在编辑器里选中节点、把 exportJson() 的结果从控制台复制出来,粘进 levels.json 即可。导出组件用完就删,不要留在正式工程里——它的存在只会让后来者以为「场景里摆的东西还会生效」,而运行时其实只认表。

8.1.9 收口

本节定下了关卡数据的形状与存放位置:

项值
表文件assets/configs/levels.json
所属 Bundlebundle-levels(assets/configs/ 目录配为 Bundle)
结构{ version, levels[] },每关含 id/name/speed/goalX/obstacles/coins
坐标x 从关卡起点算,y 以地面线为 0、向上为正,单位像素
编辑器入口LevelIndex 组件的 table 槽(仅预览用)

数据形状定了,但表是人写的,人一定会写错:漏字段、y 写成字符串、goalX 小于最后一个障碍的 x。下一节写一个校验器,把「表坏了」在加载时立刻变成一条清晰的报错,而不是运行到一半方块凭空消失。

阅读导航:上一节:目录 · 下一节:8.2 数值表加载与校验 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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