先说分工。站内的产品文档专题 是一整套 PRD 文档——需求、原型说明、验收标准,用文字与表格描述「这个产品要做成什么样」。本章不写 PRD,本章给的是可跑可调的关卡曲线模型与自动生成器:把关卡结构与难度落成整数配置表,用一段能跑的脚本算出节奏指标,再用一段能跑的内核按目标通过率反推难度参数。PRD 回答「做什么」,本章回答「怎么把它量化成能调、能校验、能自动生成的数字」。
前六章我们一路把《合成小岛》从市场、品类、平台约束、用户,收敛到核心循环(第 6 章)与最小可玩原型。但循环之外还有一个玩家实际一路走过去的东西:关卡。这一章就讲关卡——结构怎么排(7.1)、难度怎么走(7.2)、难度怎么自动校准与生成(7.3)。三节合起来是一条从「静态结构表」到「动态曲线」再到「反解参数」的链路,全程整数、可复现。
关卡结构要回答什么
「关卡」在不同游戏里含义不同。在《合成小岛》这类合成 + 放置 + 关卡的轻度小游戏里,一关就是一局有目标、有步数上限的棋盘:给定棋盘大小与步数预算,玩家要在预算内合成出某个等级的物品。于是「关卡结构设计」要回答三个可量化的问题:
- 类型配比:教学、主线、挑战、限时四类各占多少?这个比例决定整体体感是「一路平推」还是「隔几关来一次高压」。
- 峰值间隔:强度最高的挑战关 / 限时关,平均每隔几关出现一次?间隔太密玩家疲劳,太疏玩家无聊。
- 难度节奏:难度值是单调爬升,还是「爬—回落—再爬」的锯齿?锯齿里的每一次回落,就是一次给玩家的喘息。
这三个问题如果只靠「感觉」回答,做出来的关卡表就会变成一列随手填的数字。下面把它变成一张可读、可统计、可回归的配置表。
D 层:关卡结构表
先把 20 关固化成一份整数配置 config/ch07_levels.json。它和前面几节的配置一样,是「一份 config、多处消费」的那一份:本节读它统计结构,7.2 读它的 difficulty 字段跑心流模型,7.3 则反过来生成这样的 difficulty。全部是整数,difficulty 是 0–100 的难度值,moves 是步数上限,peakTypes 声明哪两类算「峰值关」。
{
"version": 1,
"game": "merge-island",
"seed": 20261012,
"note": "《合成小岛》关卡结构表(20 关)。类型:tutorial 教学 / main 主线 / challenge 挑战 / timed 限时。difficulty 为 0-100 的整数难度值;moves 为步数上限;targetTier 为目标合成等级。全部整数,无浮点。",
"typeOrder": ["tutorial", "main", "challenge", "timed"],
"peakTypes": ["challenge", "timed"],
"levels": [
{ "id": "L01", "index": 1, "type": "tutorial", "moves": 30, "targetTier": 2, "targetGold": 40, "boardCols": 4, "boardRows": 4, "difficulty": 6, "rewardGold": 60 },
{ "id": "L02", "index": 2, "type": "tutorial", "moves": 30, "targetTier": 3, "targetGold": 70, "boardCols": 4, "boardRows": 4, "difficulty": 10, "rewardGold": 70 },
{ "id": "L03", "index": 3, "type": "main", "moves": 26, "targetTier": 3, "targetGold": 90, "boardCols": 4, "boardRows": 4, "difficulty": 16, "rewardGold": 110 },
{ "id": "L04", "index": 4, "type": "main", "moves": 26, "targetTier": 4, "targetGold": 120, "boardCols": 4, "boardRows": 4, "difficulty": 20, "rewardGold": 130 },
{ "id": "L05", "index": 5, "type": "main", "moves": 24, "targetTier": 4, "targetGold": 150, "boardCols": 4, "boardRows": 4, "difficulty": 24, "rewardGold": 150 },
{ "id": "L06", "index": 6, "type": "challenge", "moves": 18, "targetTier": 5, "targetGold": 210, "boardCols": 5, "boardRows": 5, "difficulty": 40, "rewardGold": 240 },
{ "id": "L07", "index": 7, "type": "main", "moves": 24, "targetTier": 4, "targetGold": 170, "boardCols": 4, "boardRows": 4, "difficulty": 30, "rewardGold": 170 },
{ "id": "L08", "index": 8, "type": "main", "moves": 24, "targetTier": 5, "targetGold": 200, "boardCols": 4, "boardRows": 4, "difficulty": 34, "rewardGold": 190 },
{ "id": "L09", "index": 9, "type": "main", "moves": 22, "targetTier": 5, "targetGold": 230, "boardCols": 4, "boardRows": 4, "difficulty": 38, "rewardGold": 210 },
{ "id": "L10", "index": 10, "type": "timed", "moves": 20, "targetTier": 5, "targetGold": 280, "boardCols": 5, "boardRows": 5, "difficulty": 52, "rewardGold": 300 },
{ "id": "L11", "index": 11, "type": "main", "moves": 22, "targetTier": 5, "targetGold": 250, "boardCols": 4, "boardRows": 4, "difficulty": 42, "rewardGold": 230 },
{ "id": "L12", "index": 12, "type": "main", "moves": 22, "targetTier": 6, "targetGold": 280, "boardCols": 4, "boardRows": 4, "difficulty": 46, "rewardGold": 250 },
{ "id": "L13", "index": 13, "type": "main", "moves": 20, "targetTier": 6, "targetGold": 320, "boardCols": 4, "boardRows": 4, "difficulty": 50, "rewardGold": 270 },
{ "id": "L14", "index": 14, "type": "challenge", "moves": 16, "targetTier": 7, "targetGold": 400, "boardCols": 5, "boardRows": 5, "difficulty": 64, "rewardGold": 380 },
{ "id": "L15", "index": 15, "type": "main", "moves": 20, "targetTier": 6, "targetGold": 340, "boardCols": 4, "boardRows": 4, "difficulty": 54, "rewardGold": 290 },
{ "id": "L16", "index": 16, "type": "main", "moves": 20, "targetTier": 6, "targetGold": 370, "boardCols": 4, "boardRows": 4, "difficulty": 58, "rewardGold": 310 },
{ "id": "L17", "index": 17, "type": "main", "moves": 18, "targetTier": 7, "targetGold": 410, "boardCols": 4, "boardRows": 4, "difficulty": 62, "rewardGold": 330 },
{ "id": "L18", "index": 18, "type": "timed", "moves": 18, "targetTier": 7, "targetGold": 470, "boardCols": 5, "boardRows": 5, "difficulty": 76, "rewardGold": 420 },
{ "id": "L19", "index": 19, "type": "challenge", "moves": 14, "targetTier": 8, "targetGold": 560, "boardCols": 5, "boardRows": 5, "difficulty": 86, "rewardGold": 500 },
{ "id": "L20", "index": 20, "type": "main", "moves": 18, "targetTier": 7, "targetGold": 440, "boardCols": 4, "boardRows": 4, "difficulty": 72, "rewardGold": 360 }
]
}
注意几个刻意的设计:棋盘从 4×4 长到 5×5(挑战 / 限时关换更大的盘,给强度留空间),步数上限随类型收紧(主线 24–26、挑战 14–18),难度值不是单调的——L06 的 40 后面跟着 L07 的 30,这就是一次「回落」。这些都不是随手填的,下面用脚本把它们算清楚。
S 层:读表统计类型分布与节奏
下面这段是 S 层仿真脚本,读 config/ 跑结构统计,产出 JSON + CSV + 纯 Python SVG。它把上面三个问题变成四个可核对的数:类型分布(千分比)、峰值关的 index 列表与相邻间隔、难度的回落次数与净升幅。全程整数,占比用整数千分比(地板除)。
#!/usr/bin/env python3
# file: sim/ch07_level_structure.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch07_level_structure.py --config config/ --out out/
"""《合成小岛》关卡结构:读 config/ch07_levels.json,统计关卡类型分布与难度节奏。
节奏用两个可计算的量刻画:
- 峰值间隔:挑战/限时两类「峰值关」的 index 间距(整数,越小越密)
- 回落次数:难度值相对上一关下降的次数(每回落一次 = 一次喘息)
全程整数运算,难度占比用整数千分比(地板除)。
产出:out/ch07_level_structure.json + out/ch07_level_structure.csv + out/ch07_level_structure.svg
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
TYPE_CN = {"tutorial": "教学", "main": "主线", "challenge": "挑战", "timed": "限时"}
TYPE_COLOR = {"tutorial": "#10b981", "main": "#2563eb", "challenge": "#dc2626", "timed": "#f59e0b"}
def load(cfg_dir):
with open(os.path.join(cfg_dir, "ch07_levels.json"), encoding="utf-8") as f:
return json.load(f)
def per_mille(part, whole):
"""整数千分比(地板除),与 TS 侧 Math.floor(part*1000/whole) 等价。"""
return (part * 1000) // whole if whole else 0
def type_stats(levels, type_order):
counts = {t: 0 for t in type_order}
for lv in levels:
counts[lv["type"]] += 1
total = len(levels)
return [
{"type": t, "nameCn": TYPE_CN[t], "count": counts[t], "sharePermille": per_mille(counts[t], total)}
for t in type_order
]
def peak_cadence(levels, peak_types):
"""峰值关(挑战/限时)的 index 列表与相邻间距。"""
peaks = [lv["index"] for lv in levels if lv["type"] in peak_types]
gaps = [peaks[i] - peaks[i - 1] for i in range(1, len(peaks))]
return peaks, gaps
def difficulty_profile(levels):
diffs = [lv["difficulty"] for lv in levels]
descents = sum(1 for i in range(1, len(diffs)) if diffs[i] < diffs[i - 1])
return {
"min": min(diffs),
"max": max(diffs),
"meanFloor": sum(diffs) // len(diffs),
"first": diffs[0],
"last": diffs[-1],
"descentCount": descents,
"netRise": diffs[-1] - diffs[0],
}
def draw_svg(path, levels, profile):
"""纯 Python SVG:每关一根柱,颜色 = 类型,高度 = 难度值;折线 = 难度节奏。"""
n = len(levels)
W, H = 760, 380
LEFT, RIGHT, TOP, BOT = 56, 24, 64, 56
plot_w = W - LEFT - RIGHT
plot_h = H - TOP - BOT
d_max = 100
step = plot_w // n
bar_w = step - 6
svg = ET.Element(
"svg",
{"xmlns": "http://www.w3.org/2000/svg", "width": str(W), "height": str(H),
"viewBox": f"0 0 {W} {H}"},
)
ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(svg, "text", {"x": "16", "y": "24", "font-size": "15", "fill": "#111827"}).text = (
f"《合成小岛》关卡结构与难度节奏({n} 关,难度 0-100)"
)
# 图例
lx = 16
for t, c in TYPE_COLOR.items():
ET.SubElement(svg, "rect", {"x": str(lx), "y": "36", "width": "10", "height": "10", "rx": "2", "fill": c})
ET.SubElement(svg, "text", {"x": str(lx + 15), "y": "45", "font-size": "11", "fill": "#374151"}).text = TYPE_CN[t]
lx += 62
# 网格
for i in range(5):
y = TOP + plot_h * i / 4
val = d_max - d_max * i // 4
ET.SubElement(svg, "line", {"x1": str(LEFT), "y1": f"{y:.1f}", "x2": str(LEFT + plot_w), "y2": f"{y:.1f}",
"stroke": "#e5e7eb"})
ET.SubElement(svg, "text", {"x": str(LEFT - 8), "y": f"{y + 4:.1f}", "font-size": "11",
"text-anchor": "end", "fill": "#6b7280"}).text = str(val)
# 柱 + 折线
pts = []
for i, lv in enumerate(levels):
x = LEFT + i * step
h = plot_h * lv["difficulty"] // d_max
y = TOP + plot_h - h
ET.SubElement(svg, "rect", {"x": str(x + 3), "y": str(y), "width": str(bar_w), "height": str(h),
"rx": "2", "fill": TYPE_COLOR[lv["type"]], "opacity": "0.85"})
cx = x + 3 + bar_w // 2
cy = TOP + plot_h - plot_h * lv["difficulty"] // d_max
pts.append(f"{cx},{cy}")
ET.SubElement(svg, "text", {"x": str(cx), "y": str(H - BOT + 16), "font-size": "9",
"text-anchor": "middle", "fill": "#6b7280"}).text = str(lv["index"])
ET.SubElement(svg, "polyline", {"points": " ".join(pts), "fill": "none", "stroke": "#111827",
"stroke-width": "1.2", "opacity": "0.5"})
ET.SubElement(svg, "line", {"x1": str(LEFT), "y1": str(TOP + plot_h), "x2": str(LEFT + plot_w),
"y2": str(TOP + plot_h), "stroke": "#9ca3af"})
ET.SubElement(svg, "text", {"x": str(LEFT), "y": str(H - 12), "font-size": "11", "fill": "#6b7280"}).text = (
f"回落 {profile['descentCount']} 次 · 净升 {profile['netRise']} · 峰值 {profile['max']}"
)
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
return len(ET.tostring(svg))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
cfg = load(args.config)
levels = cfg["levels"]
stats = type_stats(levels, cfg["typeOrder"])
peaks, gaps = peak_cadence(levels, cfg["peakTypes"])
profile = difficulty_profile(levels)
avg_gap = (sum(gaps) // len(gaps)) if gaps else 0
report = {
"version": cfg["version"],
"game": cfg["game"],
"seed": cfg["seed"],
"levelCount": len(levels),
"typeStats": stats,
"shareSumPermille": sum(s["sharePermille"] for s in stats),
"peakIndices": peaks,
"peakGaps": gaps,
"avgPeakGap": avg_gap,
"difficulty": profile,
}
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "ch07_level_structure.svg"), levels, profile)
report["svg"] = os.path.join(args.out, "ch07_level_structure.svg")
report["svgBytes"] = svg_bytes
with open(os.path.join(args.out, "ch07_level_structure.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "ch07_level_structure.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["index", "id", "type", "typeCn", "moves", "targetTier", "targetGold",
"boardCols", "boardRows", "difficulty", "rewardGold"])
for lv in levels:
w.writerow([lv["index"], lv["id"], lv["type"], TYPE_CN[lv["type"]], lv["moves"], lv["targetTier"],
lv["targetGold"], lv["boardCols"], lv["boardRows"], lv["difficulty"], lv["rewardGold"]])
print(json.dumps({"levelCount": len(levels), "typeStats": stats,
"avgPeakGap": avg_gap, "difficulty": profile}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
跑 /opt/homebrew/bin/python3 sim/ch07_level_structure.py --config config/ --out out/,输出(同机相对量级,绝对数值不可移植):
{"levelCount": 20, "typeStats": [{"type": "tutorial", "nameCn": "教学", "count": 2, "sharePermille": 100},
{"type": "main", "nameCn": "主线", "count": 13, "sharePermille": 650},
{"type": "challenge", "nameCn": "挑战", "count": 3, "sharePermille": 150},
{"type": "timed", "nameCn": "限时", "count": 2, "sharePermille": 100}],
"avgPeakGap": 3, "difficulty": {"min": 6, "max": 86, "meanFloor": 44, "first": 6, "last": 72,
"descentCount": 4, "netRise": 66}}
怎么读这份结构表
把输出拆成三块看。
类型分布:教学 2 关(100‰)、主线 13 关(650‰)、挑战 3 关(150‰)、限时 2 关(100‰),四类占比之和恰好 1000‰。主线占 65%,说明整体节奏是「平推为主、偶尔高压」——这是轻度合成游戏的典型配比;如果挑战关占比拉到 300‰ 以上,就变成了硬核解谜的体感。
峰值间隔:峰值关(挑战 + 限时)落在 index [6, 10, 14, 18, 19],相邻间隔 [4, 4, 4, 1],平均 3 关一次峰值。前四次是稳定的「每 4 关一个高峰」,最后一次 18 → 19 间隔只有 1,是收尾阶段的连续加压——这正是「难度在末段陡增」的结构化表达。
难度节奏:难度值从 6 爬到 86(峰值),净升 66,但中途回落 4 次(每次峰值关之后紧跟一关回落的主线关,例如 40 → 30、52 → 42、64 → 54、76 → 72)。四次回落 = 四次喘息。如果一条难度曲线净升 66 却一次都不回落,玩家会一路紧绷到流失;回落的次数就是节奏的呼吸口。
| 指标 | 值 | 含义 |
|---|---|---|
| 关卡数 | 20 | 一章的量级 |
| 主线占比 | 650‰ | 平推为主 |
| 峰值关 index | 6 / 10 / 14 / 18 / 19 | 高压点位置 |
| 峰值平均间隔 | 3 关 | 每 3 关一次高压 |
| 难度回落次数 | 4 | 四次喘息口 |
| 难度净升 | 66 | 从 6 到 72(含回落后的终点) |
产出还有两张图:out/ch07_level_structure.svg 是纯 Python SVG——每关一根柱,颜色按类型区分,柱高是难度值,叠一条折线画出锯齿节奏;out/ch07_level_structure.csv 是逐关的完整字段表,可以直接丢进表格软件做人工复核。结构表、CSV、SVG 三者来自同一份 config、同一次运行,不会互相打架。
结构定了,难度怎么走
本节把关卡结构从「感觉」变成了「数字」:类型配比、峰值间隔、回落次数,都是可统计、可回归的。但它只回答了结构——每一关的难度值本身合不合理,玩家在走过这条锯齿时到底处在什么心理区间,本节还没有回答。
这正是 7.2 要做的:把 difficulty 这一列接上「玩家能力」的递推模型,看每一关落在无聊 / 心流 / 焦虑的哪一档。而 7.3 更进一步——不再手工填 difficulty,而是反过来给定目标通过率,自动反推难度参数,把这张表变成生成器的输出。
阅读导航:上一节:最小可玩原型 · 下一节:难度曲线与心流模型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。