《Cocos 游戏开发入门》13.3 关卡数据与程序化生成

敌人会寻路了,关卡却还是手画的。本节把《方块冲刺》的关卡抽成 JSON:字段设计、加载校验、随机种子保证可复现;再用约束满足生成「必定可通关」的关卡,用随距离递增的障碍密度做难度曲线;末节对照 Cocos Creator 3.8 的 TiledMap 与 Sprite 装配。

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.0maxJumpX+4 ~ +6宽松,教学
35%0.5maxJumpX+2 ~ +4节奏起来
70%1.0maxJumpX ~ +2最紧,考验操作
100%0.7maxJumpX+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 结算管线与帧对齐 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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