8.3 程序化关卡与难度曲线
8.1 与 8.2 让关卡变成了可读、可校验的数据。但数据还是人写的:摆一关要放二十个障碍、算一遍每个间距够不够跳过去。跑到第三十关时,人的注意力会先耗尽——要么开始复制粘贴(玩家立刻感觉重复),要么越做越随意(难度曲线崩掉)。
这一节反过来:让程序按一条明确的难度曲线生成关卡,人只提供种子与参数。生成出来的表同样要过 8.2 的校验器,因为生成器也会写错——而且它写错的速度比人快得多。
8.3.1 生成还是手写
不是所有关卡都该生成。判断标准是「这关的设计意图能不能用参数描述」:
| 关卡类型 | 建议 | 理由 |
|---|---|---|
| 无限模式、每日挑战 | 生成 | 需要无限内容,且每局都要不同 |
| 主线第 20 关之后的填充关 | 生成 | 设计意图单一(跑 + 跳 + 躲) |
| 教学关、Boss 关 | 手写 | 有精确的节奏与演出需求 |
| 需要美术精修的关卡 | 手写 | 视觉与碰撞体要逐像素对齐 |
《方块冲刺》的取舍是:前 5 关手写(教学),之后全部生成。这样既保证了「第一次玩的人能被教会」,又保证了「玩到第 50 关的人还有新东西」。
8.3.2 可复现的随机:种子与 PRNG
程序化生成的第一条铁律是可复现。同一个种子必须生成同一关,否则玩家重开一局看到的关卡不一样,bug 也无法复现。所以不能用 Math.random(),要自己实现一个带种子的伪随机数生成器:
// assets/scripts/core/rng.ts
export type Rng = () => number;
/** mulberry32:32 位种子、确定性、分布均匀,适合关卡生成。 */
export function makeRng(seed: number): Rng {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** 从数组里等概率取一个元素。 */
export function pick<T>(rng: Rng, list: readonly T[]): T {
return list[Math.floor(rng() * list.length)];
}
/** 取闭区间 [min, max] 内的整数。 */
export function rangeInt(rng: Rng, min: number, max: number): number {
return min + Math.floor(rng() * (max - min + 1));
}
mulberry32 只有十几行,却是很多商业游戏关卡生成器的内核:状态小(一个 32 位整数)、速度快、不需要额外依赖,而且跨平台结果完全一致——同一份代码在 H5、微信小游戏与原生端跑出来的关卡必须一模一样,否则多端联机对局就没法对账。
种子的来源要定死。推荐 seed = 基础种子 + 关卡序号 × 7919(7919 是质数,避免不同关卡用到相邻的随机序列)。基础种子存进存档:玩家分享一关时,分享的是「基础种子 + 关卡号」,对方输入后就能得到完全相同的关卡。
8.3.3 难度曲线:把「难」拆成三条可画的线
「这关太难了」是一句没用的话,因为它不可操作。拆开之后,「难」至少由三个独立的量构成:
// assets/scripts/core/difficulty.ts
export interface DifficultyBand {
/** 起始关卡序号(从 0 开始)。 */
from: number;
/** 关卡滚动速度,像素/秒。 */
speed: number;
/** 障碍之间的平均间距,像素。 */
gap: number;
/** 危险构件(尖刺、锯片)在障碍里的占比,0–1。 */
hazardRatio: number;
/** 需要跳过去的沟宽上限,像素。 */
maxGapWidth: number;
}
const BANDS: DifficultyBand[] = [
{ from: 0, speed: 300, gap: 420, hazardRatio: 0.00, maxGapWidth: 120 },
{ from: 5, speed: 320, gap: 380, hazardRatio: 0.15, maxGapWidth: 150 },
{ from: 10, speed: 350, gap: 340, hazardRatio: 0.30, maxGapWidth: 175 },
{ from: 20, speed: 380, gap: 300, hazardRatio: 0.45, maxGapWidth: 190 },
{ from: 30, speed: 420, gap: 280, hazardRatio: 0.55, maxGapWidth: 200 },
];
export function bandFor(index: number): DifficultyBand {
let current = BANDS[0];
for (const band of BANDS) {
if (index >= band.from) current = band;
}
return current;
}
三条线各有明确的心理作用:speed 压缩反应时间,gap 压缩操作密度,hazardRatio 提高失误代价。三者同时上调会让难度指数级上升,所以表里刻意让它们错开:第 5 关先加危险占比(让玩家学会「躲」),第 10 关才提速(让玩家学会「快」)。
from: 0 那一档的 hazardRatio 是 0.00——前五关一个尖刺都没有。这不是偷懒,是让玩家在无压力环境里把跳跃手感练熟。太多跑酷游戏在第一关就放尖刺,把「教学」和「考核」挤在一起,结果新玩家第一分钟就流失。
8.3.4 用最大跳跃距离约束沟宽
难度曲线里最容易出错的是 maxGapWidth。如果它超过了玩家实际能跳过去的距离,生成出来的关卡就是不可能通关的——而且这种 bug 只会在特定种子下出现,测试很难覆盖。
能跳多远是可以精确算出来的。由 7.3 的 airTime = 2v₀/g,水平距离就是滞空时间乘以滚动速度:
// assets/scripts/core/jump-arc.ts(续 7.3)
/** 一次跳跃能覆盖的最大水平距离,像素。 */
export function maxJumpDistance(v0: number, gravity: number, runSpeed: number): number {
return ((2 * v0) / gravity) * runSpeed;
}
代入 v0 = 900、gravity = 3000,逐档核对:
| 关卡段 | speed | maxGapWidth | 最大跳跃距离 | 余量 |
|---|---|---|---|---|
| 0–4 | 300 | 120 | 180 | 60 |
| 5–9 | 320 | 150 | 192 | 42 |
| 10–19 | 350 | 175 | 210 | 35 |
| 20–29 | 380 | 190 | 228 | 38 |
| 30+ | 420 | 200 | 252 | 52 |
每一档都留了 35 像素以上的余量。余量不能是零:玩家的操作有延迟,输入缓冲(7.1)只能补偿 0.12 秒,把余量压到 0 等于要求玩家每次都在极限帧起跳。经验值是余量不小于 speed × 0.1,也就是至少留出 0.1 秒的容错。
生成器里要把这条约束写成断言,而不是靠人肉核对:
// assets/scripts/core/generate-level.ts
import { makeRng, rangeInt } from './rng';
import { bandFor } from './difficulty';
import { maxJumpDistance } from './jump-arc';
import type { LevelDef, ObstacleDef, CoinDef } from './level-def';
const GROUND_Y = -150;
const V0 = 900;
const GRAVITY = 3000;
export function generateLevel(seed: number, index: number): LevelDef {
const band = bandFor(index);
const rng = makeRng(seed + index * 7919);
const reach = maxJumpDistance(V0, GRAVITY, band.speed);
const maxGap = Math.min(band.maxGapWidth, Math.floor(reach * 0.9));
const obstacles: ObstacleDef[] = [];
const coins: CoinDef[] = [];
const goalX = 2400 + index * 240;
let x = 600;
while (x < goalX - 300) {
const isHazard = rng() < band.hazardRatio;
if (isHazard) {
obstacles.push({ type: 'spike', x, y: GROUND_Y + 20, w: 40, h: 30 });
} else {
obstacles.push({ type: 'block', x, y: GROUND_Y + 30, w: rangeInt(rng, 120, 240), h: 40 });
}
const count = rangeInt(rng, 1, 3);
for (let i = 0; i < count; i += 1) {
coins.push({ x: x + i * 60, y: 40 + Math.round(rng() * 60) });
}
x += rangeInt(rng, band.gap, band.gap + 120);
}
return { id: `proc-${index + 1}`, name: `程序关 ${index + 1}`, speed: band.speed, goalX, obstacles, coins };
}
Math.min(band.maxGapWidth, reach * 0.9) 是这条约束的落点:表里写的是期望值,代码里再乘 0.9 留安全余量,两者取小。这样即使以后有人手滑把 maxGapWidth 改大了,生成器也不会产出不可能通关的关卡。生成结果仍然要过 8.2 的 validateLevelTable——那是最后一道防线。
8.3.5 在 Cocos 里把生成的关卡搭出来
生成器产出的是纯数据 LevelDef,把它变成场景里的节点由 LevelBuilder 负责:
// assets/scripts/game/level-builder.ts
import { _decorator, Component, Node, Prefab, instantiate, Vec3 } from 'cc';
const { ccclass, property } = _decorator;
interface ObstacleDef { type: string; x: number; y: number; }
interface CoinDef { x: number; y: number; }
interface BuiltLevel { obstacles: ObstacleDef[]; coins: CoinDef[]; }
@ccclass('LevelBuilder')
export class LevelBuilder extends Component {
@property({ type: Prefab }) blockPrefab: Prefab | null = null;
@property({ type: Prefab }) spikePrefab: Prefab | null = null;
@property({ type: Prefab }) coinPrefab: Prefab | null = null;
private readonly pool = new Map<string, Prefab | null>();
onLoad(): void {
this.pool.set('block', this.blockPrefab);
this.pool.set('spike', this.spikePrefab);
this.pool.set('coin', this.coinPrefab);
}
build(level: BuiltLevel): void {
for (const ob of level.obstacles) this.place(ob.type, ob.x, ob.y);
for (const c of level.coins) this.place('coin', c.x, c.y);
}
private place(kind: string, x: number, y: number): Node | null {
const prefab = this.pool.get(kind) ?? null;
if (!prefab) return null;
const node = instantiate(prefab);
node.setParent(this.node);
node.setPosition(new Vec3(x, y, 0));
return node;
}
}
三个工程细节。第一,用 Map 做「类型字符串 → 预制体」的映射,而不是一串 if/else——加一种构件只改 onLoad 一行。第二,place 在预制体缺失时返回 null 而不是抛异常:缺一个预制体不该让整关崩掉,日志里报出来、关卡照常跑,其余构件仍然可见,这比白屏好得多。第三,setParent(this.node) 把构件挂在 LevelRoot 下,7.2 的滚动逻辑才能把它们一起平移。
生成的关卡往往有上百个节点。每关结束时一定要清空,否则连着玩十关会累积上千个节点,帧率断崖式下跌。清空用 this.node.removeAllChildren(),再配合 destroy();第 13 章会讲用对象池复用这些节点。
8.3.6 编辑器侧的构件预制体
这一小节属于 L3:需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。 生成器里出现三种类型字符串,对应三个预制体,命名与分组必须严格对齐:
表里的 type | 预制体 | 碰撞体 | 分组 |
|---|---|---|---|
block | prefabs/Block.prefab | BoxCollider2D | GROUND |
spike | prefabs/Spike.prefab | BoxCollider2D | HAZARD |
saw | prefabs/Saw.prefab | CircleCollider2D | HAZARD |
| (金币) | prefabs/Coin.prefab | CircleCollider2D + Sensor | COIN |
以 Spike.prefab 为例,需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。 打开它你会看到:一个名为 Spike 的节点,挂了 cc.UITransform(_contentSize 为 40 × 30)、cc.BoxCollider2D(group 是 HAZARD 的编号)、以及脚本 Hazard:
[
{ "__type__": "cc.Prefab", "_name": "Spike", "data": { "__id__": 1 } },
{
"__type__": "cc.Node",
"_name": "Spike",
"_parent": null,
"_children": [],
"_components": [ { "__id__": 2 }, { "__id__": 3 }, { "__id__": 4 } ],
"_lpos": { "__type__": "cc.Vec3", "x": 0, "y": 0, "z": 0 },
"_layer": 33554432
},
{
"__type__": "cc.UITransform",
"node": { "__id__": 1 },
"_contentSize": { "__type__": "cc.Size", "width": 40, "height": 30 },
"_anchorPoint": { "__type__": "cc.Vec2", "x": 0.5, "y": 0.5 }
},
{
"__type__": "cc.BoxCollider2D",
"node": { "__id__": 1 },
"size": { "__type__": "cc.Size", "width": 40, "height": 30 },
"offset": { "__type__": "cc.Vec2", "x": 0, "y": 0 },
"sensor": false,
"group": 3
},
{
"__type__": "Hazard",
"node": { "__id__": 1 },
"sceneName": "main"
}
]
预制体的锚点在中心(_anchorPoint 为 0.5, 0.5),与 8.1 定的「表里存中心坐标」口径一致——这是整条链路能对齐的前提。_layer 同样是 33554432(UI_2D),group 是分组编号而非名字(在项目设置里改分组顺序会让它变),所以不要在预制体里手改 group 数字,一律在属性面板里选名字。
8.3.7 收口:这一章交出了什么
第 8 章到这里完整了。关卡从「场景里的手工节点」变成了「一份可校验、可生成、可复现的数据」:
| 类型 | 新增 |
|---|---|
| 数据结构 | core/level-def.ts(schema) |
| 纯逻辑 | core/validate-level.ts、core/rng.ts、core/difficulty.ts、core/generate-level.ts |
| 测试 | core/validate-level.test.ts |
| 运行时 | game/level-index.ts、game/level-loader.ts、game/level-service.ts、game/level-builder.ts、game/level-exporter.ts(一次性工具) |
| 资源 | configs/levels.json、prefabs/Block.prefab、prefabs/Spike.prefab、prefabs/Coin.prefab |
| 工程设置 | assets/configs/ 配为 Bundle bundle-levels |
一个完整的关卡现在只需要两样东西:一个整数种子,和一条难度曲线。玩家分享关卡时分享的是种子;线上出问题时,一个种子就能在本机复现出完全相同的关卡。
玩法两章(第 7、8 章)到此结束,但画面还停留在「会动的色块」。下一章把 Block、Spike、Coin 变成有生命的东西:用 Animation 编辑器做帧动画与属性动画,用 Tween 写程序动画,再用粒子与骨骼把金币的收集反馈做出来。
阅读导航:上一节:8.2 数值表加载与校验 · 下一节:9.1 Animation 编辑器与动画剪辑 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。