13.3 关卡数据与程序化生成
到这一节,《方块冲刺》的敌人已经会 FSM、会行为树、会 A* 绕开墙。但还有一块地基没搭:关卡从哪来。前 12 章的关卡都是一行行手写在代码里的字符串数组,改一个坑位就要重新编译。这一节把关卡抽成数据,再让程序用「约束 + 随机种子」把它生成出来,最后给出随距离递增的难度曲线。做完这一步,游戏才真正具备「无限跑」的形态。
13.3.1 为什么关卡要数据化
手写关卡有三个硬伤。第一,不可调:策划想调难度得找程序改代码。第二,不可复现:出了问题没法把「那一局」的关卡存下来回放。第三,不可扩展:跑酷游戏要的是无限长关卡,手写永远写不完。数据化的目标很直接:关卡是一份可序列化、可校验、可由种子重建的数据,代码只负责消费它。
// 反面教材:关卡硬编码在代码里
const LEVEL_1 = [
'0000000000',
'0000100000',
'0001110000',
'0000000000',
];
这段数据其实已经是「数据」了,问题在于它长在代码里:与逻辑耦合、无法热更、无法按种子生成。把它搬到 JSON 只是第一步,真正的收益来自后面几节——校验、复现与生成。
13.3.2 JSON 关卡格式:字段要少而稳
格式设计的第一原则是字段少而稳。字段越多,越难向后兼容。《方块冲刺》的关卡格式只保留四组信息:元信息、尺寸、障碍与道具列表、生成种子:
{
"version": 1,
"seed": 20261001,
"width": 64,
"height": 12,
"tileSize": 32,
"spawn": { "x": 2, "y": 4 },
"goal": { "x": 61, "y": 4 },
"obstacles": [
{ "x": 10, "y": 0, "w": 1, "h": 3, "kind": "spike" },
{ "x": 18, "y": 0, "w": 1, "h": 2, "kind": "block" }
],
"pickups": [
{ "x": 14, "y": 5, "kind": "coin" },
{ "x": 22, "y": 7, "kind": "shield" }
]
}
几点说明。version 是第一字段,任何格式演进都靠它分支处理,这是第 12 章存档迁移的同一条纪律。seed 记录生成这份关卡的随机种子,使「同一关永远长得一样」。obstacles 用「左下角 + 宽高」描述矩形障碍,比逐格标记省空间也更好做碰撞;kind 是字符串而非数字枚举,因为 JSON 没有枚举,字符串在调试时肉眼可读。tileSize 写进数据里,避免渲染层与逻辑层对格子尺寸各持一套常量。
13.3.3 加载与校验:不信任任何外部数据
关卡 JSON 可能来自关卡编辑器、后端下发,甚至是玩家改过的本地文件。永远不要相信它。加载函数的第一件事是校验,校验不过就抛错,绝不带着半个坏关卡继续跑:
export interface LevelObstacle { x: number; y: number; w: number; h: number; kind: string; }
export interface LevelPickup { x: number; y: number; kind: string; }
export interface LevelData {
version: number;
seed: number;
width: number;
height: number;
tileSize: number;
spawn: { x: number; y: number };
goal: { x: number; y: number };
obstacles: LevelObstacle[];
pickups: LevelPickup[];
}
export function parseLevel(raw: unknown): LevelData {
if (typeof raw !== 'object' || raw === null) throw new Error('关卡不是对象');
const o = raw as Record<string, unknown>;
if (o.version !== 1) throw new Error(`不支持的关卡版本:${String(o.version)}`);
const width = num(o.width, 'width');
const height = num(o.height, 'height');
if (width <= 0 || height <= 0) throw new Error('关卡尺寸非法');
const obstacles = Array.isArray(o.obstacles) ? o.obstacles.map(parseObstacle) : [];
const spawn = point(o.spawn, 'spawn');
const goal = point(o.goal, 'goal');
for (const b of obstacles) {
if (b.x < 0 || b.y < 0 || b.x + b.w > width || b.y + b.h > height) {
throw new Error(`障碍越界:(${b.x},${b.y})`);
}
}
return {
version: 1, seed: num(o.seed, 'seed'), width, height,
tileSize: num(o.tileSize, 'tileSize'),
spawn, goal, obstacles,
pickups: Array.isArray(o.pickups) ? o.pickups.map(parsePickup) : [],
};
}
function num(v: unknown, name: string): number {
if (typeof v !== 'number' || !Number.isFinite(v)) throw new Error(`字段 ${name} 不是数字`);
return v;
}
function point(v: unknown, name: string): { x: number; y: number } {
if (typeof v !== 'object' || v === null) throw new Error(`字段 ${name} 不是坐标`);
const p = v as Record<string, unknown>;
return { x: num(p.x, `${name}.x`), y: num(p.y, `${name}.y`) };
}
function parseObstacle(v: unknown): LevelObstacle {
const o = v as Record<string, unknown>;
return { x: num(o.x, 'x'), y: num(o.y, 'y'), w: num(o.w, 'w'), h: num(o.h, 'h'),
kind: typeof o.kind === 'string' ? o.kind : 'block' };
}
function parsePickup(v: unknown): LevelPickup {
const o = v as Record<string, unknown>;
return { x: num(o.x, 'x'), y: num(o.y, 'y'),
kind: typeof o.kind === 'string' ? o.kind : 'coin' };
}
校验分三层:类型层(是不是数字、是不是数组)、范围层(尺寸为正、障碍不越界)、语义层(出生点与终点不能落在障碍里)。前两层靠 num / point 这类小工具覆盖,第三层需要拿到完整数据后才能判断,所以放在最后。抛错而不是返回 null,是为了让调用方必须显式处理——坏关卡静默通过是比崩溃更难查的 bug。
13.3.4 随机种子与可复现
程序化生成的一切随机都必须走同一个种子驱动的伪随机数发生器,绝不能用 Math.random()。原因有三:同一颗种子要能重建同一张关卡;出了问题要能把种子记下来复现;联机时要让多端生成一致(卷四会用到)。用一个短小的 mulberry32 就够了:
export interface Rng {
/** 返回 [0, 1) 的浮点数 */
next(): number;
/** 返回 [min, max] 的整数 */
int(min: number, max: number): number;
/** 以概率 p 返回 true */
chance(p: number): boolean;
}
export function makeRng(seed: number): Rng {
let state = seed >>> 0;
const next = (): number => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
return {
next,
int: (min, max) => min + Math.floor(next() * (max - min + 1)),
chance: (p) => next() < p,
};
}
mulberry32 只有十几行,统计性质对游戏够用,且完全确定性:同一 seed 一定产出同一串数。注意 state 用闭包藏在 makeRng 内部,外部拿不到也改不了——这保证了「同一个 Rng 实例的调用顺序」就是随机序列本身,任何一次多余的 next() 调用都会让后续结果整体错位。生成逻辑里不能有「调试时随手多摇一次」的代码,这是复现性的头号杀手。
13.3.5 约束满足:生成「必定可通关」的关卡
纯随机撒障碍会生成过不去的关卡——比如一堵三格高的墙,玩家跳不过去。程序化生成的正确姿势是约束满足:先列出可玩性的硬约束,再在随机撒点的同时检查约束,不满足就重试或回退。跑酷关卡的硬约束可以写成三条:
- 相邻两个障碍之间的水平间距 ≥ 玩家的最大跳跃水平距离;
- 障碍高度 ≤ 玩家最大跳跃高度;
- 出生点、终点、以及每个道具点必须落在可站立的地面上。
export interface GenConfig {
width: number;
height: number;
tileSize: number;
seed: number;
/** 玩家最大跳跃水平距离(格) */
maxJumpX: number;
/** 玩家最大跳跃高度(格) */
maxJumpY: number;
}
export function generateLevel(cfg: GenConfig): LevelData {
const rng = makeRng(cfg.seed);
const ground = Math.floor(cfg.height / 3);
const obstacles: LevelObstacle[] = [];
const pickups: LevelPickup[] = [];
let x = 4;
while (x < cfg.width - 6) {
const gap = rng.int(cfg.maxJumpX, cfg.maxJumpX + 3);
const h = rng.int(1, cfg.maxJumpY);
obstacles.push({ x, y: ground, w: 1, h, kind: rng.chance(0.5) ? 'block' : 'spike' });
if (rng.chance(0.6)) {
// 道具放在障碍上方,鼓励玩家跳跃收集
pickups.push({ x: x + 1, y: ground + h + 2, kind: rng.chance(0.2) ? 'shield' : 'coin' });
}
x += gap + 1;
}
return {
version: 1,
seed: cfg.seed,
width: cfg.width,
height: cfg.height,
tileSize: cfg.tileSize,
spawn: { x: 2, y: ground + 1 },
goal: { x: cfg.width - 3, y: ground + 1 },
obstacles,
pickups,
};
}
关键在 gap = rng.int(cfg.maxJumpX, cfg.maxJumpX + 3):间距的下界被硬约束锁定为 maxJumpX,随机只在「多宽」上发挥。h = rng.int(1, cfg.maxJumpY) 同理,高度上界锁死在跳跃高度内。这样一来,无论种子怎么变,生成的关卡天然满足可通关约束,不需要生成后再跑一次「可达性检查」再重试——把约束写进生成规则,比事后修补便宜得多。
13.3.6 难度曲线:随距离递增的障碍密度
无限跑酷不能一直是同一个难度,否则玩家三分钟就腻。难度靠障碍密度随距离递增来体现。做法是把上面固定的间距改成一个随 x 收缩的函数,同时对下界做钳制,保证再怎么难也仍然可跳过:
/** 难度系数:0 到 1,随关卡进度线性上升,后期放缓 */
export function difficultyAt(x: number, width: number): number {
const t = Math.min(1, x / width);
return t < 0.7 ? t / 0.7 : 1 - (t - 0.7) * 0.3;
}
/** 间距随难度收缩,但永不低于 maxJumpX */
export function gapAt(x: number, width: number, maxJumpX: number, rng: Rng): number {
const d = difficultyAt(x, width);
const base = maxJumpX + Math.round((1 - d) * 4);
return rng.int(base, base + 2);
}
difficultyAt 的形状值得说一句:它在前 70% 线性升到 1,后 30% 略微回落——这是给玩家喘息的经典曲线,一路拉满会让后半程变成纯背板。gapAt 里 base 的最小值就是 maxJumpX,难度再高也不会突破物理上限。把难度写成一个纯函数而不是散落的 if (x > 100),好处是曲线可以整体替换、单测可以直接对着它断言,改数值不用动生成逻辑。
| 进度 | difficulty | 间距范围 | 手感 |
|---|---|---|---|
| 0% | 0.0 | maxJumpX+4 ~ +6 | 宽松,教学 |
| 35% | 0.5 | maxJumpX+2 ~ +4 | 节奏起来 |
| 70% | 1.0 | maxJumpX ~ +2 | 最紧,考验操作 |
| 100% | 0.7 | maxJumpX+1 ~ +3 | 收尾,略微放松 |
13.3.7 Cocos 对照:TiledMap 装配与编辑器
在 Cocos Creator 3.8 里,关卡数据的落点有两个层次。表现层用 TiledMap 组件把 Tiled 编辑器产出的 .tmx 直接渲染成一张地图:TiledMap extends Component,getMapSize(): math.Size 给尺寸,getLayer(name): TiledLayer | null 取图层,getObjectGroup(groupName: string): TiledObjectGroup | null 取对象层——出生点、终点、道具点这类「非格子」数据,正是 Tiled 的对象层擅长表达的,与 13.3.2 里 spawn / goal / pickups 一一对应。逻辑层则完全可以用本节写的 LevelData:从对象层读出坐标,填进 LevelData,再交给与 mini2d 完全相同的校验与生成代码。
import { _decorator, Component, TiledMap, TiledObjectGroup, Sprite, Node } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('LevelBuilder')
export class LevelBuilder extends Component {
@property(TiledMap)
public map: TiledMap | null = null;
@property(Sprite)
public obstaclePrefab: Sprite | null = null;
build(data: LevelData): void {
// 逻辑数据 -> 表现节点:每个障碍实例化一个 Sprite
for (const b of data.obstacles) {
const node = new Node();
node.name = `obstacle_${b.x}_${b.y}`;
const sprite = node.addComponent(Sprite);
sprite.spriteFrame = this.obstaclePrefab?.spriteFrame ?? null;
node.setPosition(b.x * data.tileSize, b.y * data.tileSize);
this.node.addChild(node);
}
}
readObjects(): void {
const group: TiledObjectGroup | null = this.map?.getObjectGroup('markers') ?? null;
if (!group) return;
// group 里的对象携带 x / y / name,可映射成 spawn / goal / pickups
}
}
cc.d.ts 里 Sprite extends UIRenderer,node.setPosition(x, y, z?) 来自 Node,node.addComponent<T>(...) 返回组件实例——把逻辑坐标乘上 tileSize 再 setPosition,就是把 LevelData 变成可见关卡的完整链路。
最后是编辑器侧。下面这份 .prefab 片段描述了一个障碍物预制体,需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现——它的意义在于让读者知道「在编辑器里会看到一个挂着 Sprite 组件的空节点」:
{
"__type__": "cc.Prefab",
"data": {
"__type__": "cc.Node",
"name": "obstacle",
"_components": [
{ "__type__": "cc.Sprite", "_spriteFrame": { "__uuid__": "..." } }
]
}
}
在编辑器里打开这个 .prefab,层级面板会出现一个名为 obstacle 的节点,属性检查器里挂着 Sprite 组件,其 SpriteFrame 指向图集里的一张图。把 13.3.5 生成的 LevelData 喂给 LevelBuilder.build,运行时就会按数据把这个预制体的实例摆满整张关卡——至此,「数据 → 生成 → 渲染」的闭环打通,下一章我们进入数值与玩法系统。
阅读导航:上一节:13.2 寻路:A* 与导航网格 · 下一节:14.1 结算管线与帧对齐 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。