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 轴方向出岔子
配置表最容易出错的地方不是结构,而是坐标口径。定死三条:
- x 从关卡起点算,不是从屏幕左边缘算。关卡起点对应
LevelRoot的初始位置,玩家出生点在x = 0附近。 - y 以地面线为 0,向上为正。地面线的世界坐标在 960×540 的设计分辨率下取
y = -150(画布中心为原点),所以表里的y: -120表示「在地面线下方 120 像素」——那是实心方块;y: 40表示「地面线上方 40 像素」——那是金币。 - 宽度与高度一律是像素,与设计分辨率同单位。不要混用「格」或「米」。
这三条写进 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 编辑器打开,本节不可命令行复现。 可照做的步骤:
- 资源管理器里选中
assets/configs目录。 - 属性面板勾选「配置为 Bundle」,把
Bundle 名称填成bundle-levels。 优先级填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 |
| 所属 Bundle | bundle-levels(assets/configs/ 目录配为 Bundle) |
| 结构 | { version, levels[] },每关含 id/name/speed/goalX/obstacles/coins |
| 坐标 | x 从关卡起点算,y 以地面线为 0、向上为正,单位像素 |
| 编辑器入口 | LevelIndex 组件的 table 槽(仅预览用) |
数据形状定了,但表是人写的,人一定会写错:漏字段、y 写成字符串、goalX 小于最后一个障碍的 x。下一节写一个校验器,把「表坏了」在加载时立刻变成一条清晰的报错,而不是运行到一半方块凭空消失。
阅读导航:上一节:目录 · 下一节:8.2 数值表加载与校验 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。