照例先划边界。站内的产品文档专题
会写「目标通过率 60%」这类验收指标,但不会给出怎么把它变成关卡参数。本节给的是可跑可调的关卡曲线模型与自动生成器:一段整数内核,输入目标通过率、输出难度参数,再用断言把「每关回代通过率都在容差内」锁死。这不是又一份 PRD,而是一个能 node --test 真跑、能跨语言逐位对齐的校准器。
7.1 排了结构,7.2 评估了心流,但两节的难度值都是手工填的。本节把它反过来:先定目标通过率,再反推难度。这是关卡设计的真实工作流——策划先想「第 1 关要让绝大多数人过、最后一关要压到一半人卡住」,再让参数去满足这个目标。
校准问题:从「填难度」到「反推难度」
先把「通过率」变成一个可计算的模型。我们假设:能力高于难度的每一分,都换算成通过率的千分点。用一条线性模型:
pass = clamp(passMid + (ability - difficulty) * slopeNum / slopeDen, min, max)
passMid 是能力与难度相等时的基准通过率(500‰),slopeNum/slopeDen 是「每 1 点能力余量值多少通过率」。反解就是把公式倒过来——给定 ability 与目标 pass,求 difficulty:
difficulty = ability - (target - passMid) * slopeDen / slopeNum
两条式子都用整数地板除,slopeDen = 1 时反解误差恒小于 slopeNum。加上一个可播种抖动(用配置里的 seed + 整数 LCG,禁用 Math.random()),生成器就能产出「整体沿目标曲线下降、但每关略有起伏」的关卡难度——太完美的曲线反而不真实,玩家会察觉。
D 层:校准配置
参数固化成 config/ch07_calibration.json。全部整数:slopeNum/slopeDen = 12/1 是能力—通过率斜率,targetPassStart/End = 880/520 是首末关的目标通过率(首关易、末关难),jitterPassMax = 40 是抖动幅度(千分点),tolerancePermille = 20 是回代容差。
{
"version": 1,
"game": "merge-island",
"seed": 20261012,
"note": "关卡难度校准:按目标通过率反推难度参数。通过率模型 passPermille = clamp(passMid + (ability - difficulty) * slopeNum / slopeDen, min, max);反解 difficulty = ability - (target - passMid) * slopeDen / slopeNum(整数地板除)。生成器用可播种 LCG 对目标通过率做抖动,core/ 内禁用 Math.random()。",
"abilityStart": 40,
"abilityGainPerLevel": 2,
"slopeNum": 12,
"slopeDen": 1,
"passMidPermille": 500,
"passMinPermille": 50,
"passMaxPermille": 980,
"jitterPassMax": 40,
"tolerancePermille": 20,
"levelCount": 12,
"targetPassStart": 880,
"targetPassEnd": 520,
"rngMultiplier": 1664525,
"rngIncrement": 1013904223
}
P 层:按目标通过率反推难度
下面是 P 层内核,一份完整的、可直接 import 的 TypeScript 模块。它有三个硬约束:无 Math.random()(抖动走 createRng)、除法一律整数地板除(Math.floor,负数也走地板)、反解可逆(solveDifficulty 是 passPermille 的逆函数)。
// file: core/src/ch07/calibration.ts
// tier: P
// verify: node --test 'core/test/ch07_*.test.ts'
//
// 《合成小岛》关卡难度校准内核:按目标通过率反推难度参数,可播种生成。
// 全整数、地板除、无 Math.random()(随机数走 createRng)。
// 只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器)。
export interface CalibConfig {
readonly version: number;
readonly seed: number;
readonly abilityStart: number;
readonly abilityGainPerLevel: number;
readonly slopeNum: number;
readonly slopeDen: number;
readonly passMidPermille: number;
readonly passMinPermille: number;
readonly passMaxPermille: number;
readonly jitterPassMax: number;
readonly tolerancePermille: number;
readonly levelCount: number;
readonly targetPassStart: number;
readonly targetPassEnd: number;
readonly rngMultiplier: number;
readonly rngIncrement: number;
}
export interface Rng { next(): number; state(): number }
export interface CalibratedLevel {
index: number;
ability: number;
targetPass: number;
jitter: number;
targetJitter: number;
difficulty: number;
modeledPass: number;
inTolerance: boolean;
}
/** 可播种整数 LCG:s = (s * mul + inc) mod 2^32。同一 seed 必得同一序列。 */
export function createRng(seed: number, cfg: CalibConfig): Rng {
let s = seed >>> 0;
return {
next() { s = (Math.imul(s, cfg.rngMultiplier) + cfg.rngIncrement) >>> 0; return s; },
state() { return s; },
};
}
export function clamp(v: number, lo: number, hi: number): number {
return v < lo ? lo : v > hi ? hi : v;
}
/** 通过率千分比:能力余量 (ability-difficulty) 越大越容易过,钳在 [min,max]。 */
export function passPermille(ability: number, difficulty: number, cfg: CalibConfig): number {
const margin = ability - difficulty;
const raw = cfg.passMidPermille + Math.floor((margin * cfg.slopeNum) / cfg.slopeDen);
return clamp(raw, cfg.passMinPermille, cfg.passMaxPermille);
}
/** 反解:给定能力与目标通过率,求应设的难度值(整数地板除,非钳制)。 */
export function solveDifficulty(ability: number, targetPass: number, cfg: CalibConfig): number {
const margin = Math.floor(((targetPass - cfg.passMidPermille) * cfg.slopeDen) / cfg.slopeNum);
return ability - margin;
}
/** 生成一整套关卡:目标通过率线性下降 + 可播种抖动,逐关反推难度并复核通过率。 */
export function generate(cfg: CalibConfig): CalibratedLevel[] {
const rng = createRng(cfg.seed, cfg);
const out: CalibratedLevel[] = [];
const span = cfg.levelCount - 1;
const lo = cfg.passMinPermille + cfg.slopeNum;
const hi = cfg.passMaxPermille - cfg.slopeNum;
for (let i = 0; i < cfg.levelCount; i += 1) {
const ability = cfg.abilityStart + cfg.abilityGainPerLevel * i;
const targetPass = cfg.targetPassStart
+ Math.floor(((cfg.targetPassEnd - cfg.targetPassStart) * i) / span);
const jitter = (rng.next() % (2 * cfg.jitterPassMax + 1)) - cfg.jitterPassMax;
const targetJitter = clamp(targetPass + jitter, lo, hi);
const difficulty = solveDifficulty(ability, targetJitter, cfg);
const modeledPass = passPermille(ability, difficulty, cfg);
out.push({
index: i + 1, ability, targetPass, jitter, targetJitter,
difficulty, modeledPass, inTolerance: Math.abs(modeledPass - targetJitter) <= cfg.tolerancePermille,
});
}
return out;
}
内核只有五个导出,但覆盖了校准的完整链路:随机(createRng)、模型(passPermille)、反解(solveDifficulty)、生成(generate)、钳制(clamp)。注意 generate 是确定性的——它只依赖 cfg.seed,同一份配置在任何机器上都生成同一套关卡。这一点是后面「断言 + 跨语言对照」的前提。
P 层:把不变量写成断言
内核写完了,怎么证明它是对的?断言。下面这份测试用 node --test 直跑,把校准的每一条不变量锁死。
// file: core/test/ch07_calibration.test.ts
// tier: P
// verify: node --test 'core/test/ch07_*.test.ts'
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { createRng, clamp, passPermille, solveDifficulty, generate } from "../src/ch07/calibration.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const cfg = JSON.parse(
readFileSync(join(HERE, "..", "..", "config", "ch07_calibration.json"), "utf-8"),
) as Parameters<typeof createRng>[1];
test("LCG 可播种:同一 seed 得同一前 5 个输出", () => {
const rng = createRng(cfg.seed, cfg);
assert.deepEqual([rng.next(), rng.next(), rng.next(), rng.next(), rng.next()],
[1891695331, 1516053222, 1468488973, 3014056712, 989151943]);
});
test("通过率随难度单调下降,能力固定 50", () => {
assert.equal(passPermille(50, 30, cfg), 740);
assert.equal(passPermille(50, 40, cfg), 620);
assert.equal(passPermille(50, 50, cfg), 500);
});
test("通过率钳在 [min,max]:难度远高于能力时不低于下限", () => {
assert.equal(passPermille(40, 1000, cfg), cfg.passMinPermille);
assert.equal(passPermille(1000, 0, cfg), cfg.passMaxPermille);
assert.equal(clamp(5, 50, 980), 50);
});
test("反解可逆:solveDifficulty 后回代的误差小于斜率步长", () => {
const d = solveDifficulty(40, 880, cfg);
assert.equal(d, 9);
assert.ok(Math.abs(passPermille(40, d, cfg) - 880) < cfg.slopeNum);
});
test("生成器可复现:同 seed 两次 generate 逐字段相同", () => {
assert.deepEqual(generate(cfg), generate(cfg));
});
test("每一关回代通过率都落在容差内", () => {
const g = generate(cfg);
assert.equal(g.length, cfg.levelCount);
assert.ok(g.every((r) => r.inTolerance), "存在超容差的关卡");
});
test("目标通过率严格递减、能力严格递增、难度严格递增", () => {
const g = generate(cfg);
for (let i = 1; i < g.length; i += 1) {
assert.ok(g[i].targetPass < g[i - 1].targetPass, `targetPass 未递减 @${i + 1}`);
assert.ok(g[i].ability > g[i - 1].ability, `ability 未递增 @${i + 1}`);
assert.ok(g[i].difficulty > g[i - 1].difficulty, `difficulty 未递增 @${i + 1}`);
}
});
test("抖动落在 ±jitterPassMax 内", () => {
const g = generate(cfg);
assert.ok(g.every((r) => Math.abs(r.jitter) <= cfg.jitterPassMax));
});
test("确定性输出:首关难度 10 / 通过率 860,末关难度 62 / 通过率 500", () => {
const g = generate(cfg);
assert.deepEqual(
{ d: g[0].difficulty, p: g[0].modeledPass, j: g[0].jitter },
{ d: 10, p: 860, j: -12 });
assert.deepEqual(
{ d: g[11].difficulty, p: g[11].modeledPass, j: g[11].jitter },
{ d: 62, p: 500, j: -9 });
});
跑 node --test 'core/test/ch07_*.test.ts',输出(同机相对量级,绝对数值不可移植):
ℹ tests 9
ℹ pass 9
ℹ fail 0
九条断言全过。最有分量的是「每一关回代通过率都落在容差内」与「确定性输出」两条:前者保证反解真的有效,后者把 12 关的难度与通过率硬编码成不变量——任何人改了通过率模型或抖动实现,这两条会立刻红。
怎么读:容差、抖动与生成结果
把 12 关的生成结果摊开看(out/ch07_calibration.csv 的同一份数据):
| 关 | 能力 | 目标通过率 | 抖动后目标 | 难度 | 回代通过率 | 误差‰ |
|---|---|---|---|---|---|---|
| 1 | 40 | 880 | 868 | 10 | 860 | 8 |
| 2 | 42 | 847 | 843 | 14 | 836 | 7 |
| 3 | 44 | 814 | 814 | 18 | 812 | 2 |
| 4 | 46 | 781 | 797 | 22 | 788 | 9 |
| 5 | 48 | 749 | 740 | 28 | 740 | 0 |
| 6 | 50 | 716 | 728 | 31 | 728 | 0 |
| 7 | 52 | 683 | 689 | 37 | 680 | 9 |
| 8 | 54 | 650 | 686 | 39 | 680 | 6 |
| 9 | 56 | 618 | 658 | 43 | 656 | 2 |
| 10 | 58 | 585 | 599 | 50 | 596 | 3 |
| 11 | 60 | 552 | 574 | 54 | 572 | 2 |
| 12 | 62 | 520 | 511 | 62 | 500 | 11 |
三件事值得注意。难度从 10 升到 62,是标准的递进曲线;回代通过率全部在容差 20‰ 内(最大误差 11‰),说明反解有效;抖动让曲线不是完美单调——比如第 7、8 关的回代通过率都是 680‰,因为抖动把第 8 关的目标临时抬高了(650 → 686)。这正是「可播种抖动」的价值:它产出的曲线像人排的,而不像公式算的,但每一关又都可复现、可回代校验。
跨语言一致性
本套的核心主张是「一份 config、三种消费」。这份校准配置也能被 Python 侧复刻:sim/ch07_calibration.py 用同一套整数地板除与同一个 LCG,产出 JSON + CSV + SVG,与 TS 内核逐位对齐。实测(同机相对量级,绝对数值不可移植):
$ /opt/homebrew/bin/python3 sim/ch07_calibration.py --config config/ --out out/
{"levelCount": 12, "inTolerance": 12, "maxAbsErrorPermille": 11, "difficultyFirst": 10, "difficultyLast": 62}
# 逐关对比 TS generate() 与 Python generate() 的 6 个字段:TS==PY: true
12 关 × 6 个字段(能力 / 目标 / 抖动 / 抖动后目标 / 难度 / 回代通过率)全部相等。这意味着难度校准不是「某一种语言的实现细节」,而是一份语言无关的规格——Python 仿真算曲线、TS 内核算规则、未来的浏览器原型给人调参,三者用同一套数字对齐。
本章小结与下一章
第 7 章三节走完了关卡设计的完整链路:7.1 把结构变成可统计的表(类型分布、峰值间隔、回落节奏),7.2 把难度接上玩家能力(无聊 / 心流 / 焦虑三档 + 参数敏感性),7.3 把难度反过来从目标通过率生成(可逆反解 + 可播种抖动 + 跨语言一致)。三节共用同一份 difficulty 数据流,全程整数、可复现、可断言。
但关卡只是玩家一局的体验。玩家为什么会「再来一局」、长线为什么留得住,靠的是关卡之外的另一套东西:成长线、进度与放置收益——升级曲线怎么走、离线收益怎么给、什么时候给玩家「就差一点」的钩子。这是第 8 章的内容,它同样会用一套可跑的整数曲线模型来回答。
阅读导航:上一节:难度曲线与心流模型 · 下一节:成长线与数值曲线 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。