7.1 关卡结构设计

把《合成小岛》的 20 个关卡写成一张整数结构表:用 S 层脚本读表统计教学/主线/挑战/限时四类的占比、峰值关的相邻间隔与难度回落节奏,产出 CSV 与纯 Python 绘制的 SVG 图,让关卡结构从主观感觉变成可统计、可回归、可复核的数字。

先说分工。站内的产品文档专题 是一整套 PRD 文档——需求、原型说明、验收标准,用文字与表格描述「这个产品要做成什么样」。本章不写 PRD,本章给的是可跑可调的关卡曲线模型与自动生成器:把关卡结构与难度落成整数配置表,用一段能跑的脚本算出节奏指标,再用一段能跑的内核按目标通过率反推难度参数。PRD 回答「做什么」,本章回答「怎么把它量化成能调、能校验、能自动生成的数字」。

前六章我们一路把《合成小岛》从市场、品类、平台约束、用户,收敛到核心循环(第 6 章)与最小可玩原型。但循环之外还有一个玩家实际一路走过去的东西:关卡。这一章就讲关卡——结构怎么排(7.1)、难度怎么走(7.2)、难度怎么自动校准与生成(7.3)。三节合起来是一条从「静态结构表」到「动态曲线」再到「反解参数」的链路,全程整数、可复现。

关卡结构要回答什么

「关卡」在不同游戏里含义不同。在《合成小岛》这类合成 + 放置 + 关卡的轻度小游戏里,一关就是一局有目标、有步数上限的棋盘:给定棋盘大小与步数预算,玩家要在预算内合成出某个等级的物品。于是「关卡结构设计」要回答三个可量化的问题:

  1. 类型配比:教学、主线、挑战、限时四类各占多少?这个比例决定整体体感是「一路平推」还是「隔几关来一次高压」。
  2. 峰值间隔:强度最高的挑战关 / 限时关,平均每隔几关出现一次?间隔太密玩家疲劳,太疏玩家无聊。
  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‰平推为主
峰值关 index6 / 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,而是反过来给定目标通过率,自动反推难度参数,把这张表变成生成器的输出。

阅读导航:上一节:最小可玩原型 · 下一节:难度曲线与心流模型 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制