7.2 难度曲线与心流模型

把难度值与玩家能力接成一条整数滞后追赶的递推曲线:玩家能力追不上突然跳高的峰值关时判定为焦虑、难度回落后能力追上则回到心流,逐关算出无聊/心流/焦虑三档分布,再用 B 层基准对每关难度做参数敏感性扰动,看心流占比会漂多少、单关扰动如何级联影响后续关卡。

先划边界。站内的产品文档专题 把难度写成 PRD 里的验收描述——「第 6 关开始出现连续三次合成目标」这类文字约定。本节不写文字约定,本节给的是可跑可调的关卡曲线模型:把难度值和玩家能力接成一条整数递推曲线,逐关算出玩家到底落在无聊、心流还是焦虑区间,并给出这套模型对参数扰动的敏感性。PRD 说「难度应该递进」,本节说「递进多少、在哪几关会掉出心流」。

7.1 我们已经有了 20 关的 difficulty 列。但一列难度值本身没有意义——难不难是相对玩家能力说的。同样是 40 的难度,对能力 60 的玩家是「有点意思」,对能力 20 的玩家是「打不过」。本节就把这一列接上能力,把它变成一条心流曲线。

难度曲线与心流:把两列数接起来

心流理论的核心一句话是:当挑战难度略高于当前能力时,人进入专注;难度远高于能力会焦虑,远低于能力会无聊。把它落成可计算的模型,需要一个「能力如何随关卡增长」的假设。我们用一条整数滞后追赶规则:

ability_{i+1} = ability_i + (difficulty_i - ability_i) // lag + learnBonus
gap_i         = difficulty_i - ability_i

含义是:玩家每过一关,能力向「本关难度」追近 1/lag,再加一点被动熟练度 learnBonus。这样能力滞后于难度——难度突然跳高(峰值关),能力追不上,gap 变正,玩家焦虑;随后几关难度回落,能力追上,gap 回到零附近,玩家回到心流。全程整数、地板除,负数也走地板(与 TS 侧 Math.floor 一致),可复现。

D 层:能力模型配置

难度序列取自 7.1 的 config/ch07_levels.json,能力模型的参数单独放 config/ch07_flow.json——两份 config 各管一段,同一份难度表被本节与 7.1 共同消费。全部整数,flowBand 是心流区间半宽。

{
  "version": 1,
  "game": "merge-island",
  "seed": 20261012,
  "note": "心流模型:玩家能力按整数滞后追赶难度——ability += (difficulty - ability) // lag + learnBonus,全程整数地板除。|difficulty - ability| <= flowBand 记为心流;正超为焦虑、负超为无聊。难度序列取自 config/ch07_levels.json,本文件只提供能力模型参数。",
  "abilityStart": 8,
  "lag": 3,
  "learnBonus": 1,
  "flowBand": 10,
  "benchPerturbPct": 25
}

S 层:难度 vs 能力的心流模型

下面这段是 S 层仿真脚本,读 config/ 跑 20 关的心流递推,产出 JSON + CSV + 纯 Python SVG。SVG 里红折线是难度、蓝折线是能力、浅蓝带是能力 ±flowBand 的心流区间;难度线冲出带外的关,就是焦虑关。

#!/usr/bin/env python3
# file: sim/ch07_difficulty_flow.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch07_difficulty_flow.py --config config/ --out out/
"""《合成小岛》难度曲线与心流模型。

难度序列来自 config/ch07_levels.json,能力模型参数来自 config/ch07_flow.json:
  ability_{i+1} = ability_i + (difficulty_i - ability_i) // lag + learnBonus   (整数地板除)
  gap_i         = difficulty_i - ability_i
  区间:gap > flowBand 焦虑 / gap < -flowBand 无聊 / 其余心流
产出:out/ch07_difficulty_flow.json + out/ch07_difficulty_flow.csv + out/ch07_difficulty_flow.svg
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET


def load_levels(cfg_dir):
    with open(os.path.join(cfg_dir, "ch07_levels.json"), encoding="utf-8") as f:
        return json.load(f)


def load_flow(cfg_dir):
    with open(os.path.join(cfg_dir, "ch07_flow.json"), encoding="utf-8") as f:
        return json.load(f)


def floor_div(a, b):
    """整数地板除(与 TS Math.floor(a/b) 等价,含负数)。"""
    return a // b


def zone_of(gap, band):
    if gap > band:
        return "anxious"
    if gap < -band:
        return "bored"
    return "flow"


def simulate(difficulties, flow):
    """按整数滞后追赶递推,返回逐关状态。"""
    band = flow["flowBand"]
    ability = flow["abilityStart"]
    rows = []
    for i, d in enumerate(difficulties):
        gap = d - ability
        rows.append({"index": i + 1, "difficulty": d, "ability": ability, "gap": gap,
                     "zone": zone_of(gap, band)})
        ability = ability + floor_div(d - ability, flow["lag"]) + flow["learnBonus"]
    return rows


def summarize(rows):
    n = len(rows)
    counts = {"flow": 0, "bored": 0, "anxious": 0}
    for r in rows:
        counts[r["zone"]] += 1
    return {
        "levelCount": n,
        "flowCount": counts["flow"],
        "boredCount": counts["bored"],
        "anxiousCount": counts["anxious"],
        "flowPermille": (counts["flow"] * 1000) // n if n else 0,
        "maxGap": max(r["gap"] for r in rows),
        "minGap": min(r["gap"] for r in rows),
    }


def draw_svg(path, rows, flow, summary):
    W, H = 760, 380
    LEFT, RIGHT, TOP, BOT = 56, 120, 56, 48
    plot_w = W - LEFT - RIGHT
    plot_h = H - TOP - BOT
    vmax = 100
    n = len(rows)
    sx = lambda i: LEFT + (plot_w * i) // (n - 1)
    sy = lambda v: TOP + plot_h - (plot_h * v) // vmax
    band = flow["flowBand"]
    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 = (
        "难度 vs 玩家能力:心流区间 ±" + str(band) + f"(心流 {summary['flowPermille']}‰)"
    )
    for i in range(5):
        y = TOP + plot_h * i / 4
        val = vmax - vmax * 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)
    # 心流带(能力 ±band 的浅蓝区域)
    upper = " ".join(f"{sx(i)},{sy(min(vmax, r['ability'] + band))}" for i, r in enumerate(rows))
    lower = " ".join(f"{sx(i)},{sy(max(0, r['ability'] - band))}" for i, r in reversed(list(enumerate(rows))))
    ET.SubElement(svg, "polygon", {"points": upper + " " + lower, "fill": "#93c5fd", "opacity": "0.25"})
    # 难度线 + 能力线
    dpts = " ".join(f"{sx(i)},{sy(r['difficulty'])}" for i, r in enumerate(rows))
    apts = " ".join(f"{sx(i)},{sy(r['ability'])}" for i, r in enumerate(rows))
    ET.SubElement(svg, "polyline", {"points": dpts, "fill": "none", "stroke": "#dc2626", "stroke-width": "2"})
    ET.SubElement(svg, "polyline", {"points": apts, "fill": "none", "stroke": "#2563eb", "stroke-width": "2"})
    # 焦虑关标记
    for i, r in enumerate(rows):
        if r["zone"] == "anxious":
            ET.SubElement(svg, "circle", {"cx": str(sx(i)), "cy": str(sy(r["difficulty"])), "r": "4",
                                          "fill": "#dc2626", "stroke": "#ffffff"})
    # 图例
    ET.SubElement(svg, "line", {"x1": str(W - 108), "y1": "40", "x2": str(W - 88), "y2": "40",
                                "stroke": "#dc2626", "stroke-width": "2"})
    ET.SubElement(svg, "text", {"x": str(W - 82), "y": "44", "font-size": "11", "fill": "#374151"}).text = "难度"
    ET.SubElement(svg, "line", {"x1": str(W - 108), "y1": "58", "x2": str(W - 88), "y2": "58",
                                "stroke": "#2563eb", "stroke-width": "2"})
    ET.SubElement(svg, "text", {"x": str(W - 82), "y": "62", "font-size": "11", "fill": "#374151"}).text = "能力"
    ET.SubElement(svg, "rect", {"x": str(W - 108), "y": "70", "width": "20", "height": "10",
                                "fill": "#93c5fd", "opacity": "0.5"})
    ET.SubElement(svg, "text", {"x": str(W - 82), "y": "79", "font-size": "11", "fill": "#374151"}).text = "心流带"
    ET.SubElement(svg, "text", {"x": str(LEFT), "y": str(H - 14), "font-size": "11", "fill": "#6b7280"}).text = (
        f"焦虑 {summary['anxiousCount']} · 心流 {summary['flowCount']} · 无聊 {summary['boredCount']}"
    )
    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()

    levels = load_levels(args.config)
    flow = load_flow(args.config)
    difficulties = [lv["difficulty"] for lv in levels["levels"]]
    rows = simulate(difficulties, flow)
    summary = summarize(rows)

    report = {
        "version": flow["version"],
        "game": flow["game"],
        "seed": flow["seed"],
        "abilityStart": flow["abilityStart"],
        "lag": flow["lag"],
        "learnBonus": flow["learnBonus"],
        "flowBand": flow["flowBand"],
        "summary": summary,
        "rows": rows,
    }

    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "ch07_difficulty_flow.svg"), rows, flow, summary)
    report["svg"] = os.path.join(args.out, "ch07_difficulty_flow.svg")
    report["svgBytes"] = svg_bytes
    with open(os.path.join(args.out, "ch07_difficulty_flow.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "ch07_difficulty_flow.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["index", "difficulty", "ability", "gap", "zone"])
        for r in rows:
            w.writerow([r["index"], r["difficulty"], r["ability"], r["gap"], r["zone"]])

    print(json.dumps({"flowBand": flow["flowBand"], "summary": summary}, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

跑 /opt/homebrew/bin/python3 sim/ch07_difficulty_flow.py --config config/ --out out/,输出(同机相对量级,绝对数值不可移植):

{"flowBand": 10, "summary": {"levelCount": 20, "flowCount": 15, "boredCount": 0,
 "anxiousCount": 5, "flowPermille": 750, "maxGap": 21, "minGap": -2}}

结果很干净:20 关里 15 关落在心流(750‰)、5 关焦虑、0 关无聊。焦虑关恰好是 index 6 / 10 / 14 / 18 / 19——与 7.1 算出的峰值关(挑战 / 限时)逐个重合。maxGap = 21、minGap = -2:难度最多高出能力 21 点(L06、L19 的峰值),最多低于能力 2 点,说明这条曲线整体略偏难但从不无聊。这不是巧合,而是 7.1 那张结构表里「峰值关 + 紧随其后的回落关」共同作用的结果。

B 层:难度参数敏感性

模型跑通了,但它稳不稳?下面这段是 B 层基准脚本,把每一关的 difficulty 单独上下扰动 benchPerturbPct(25%),重跑心流模型,看心流占比漂多少。B 层是软告警:漂移超阈值只打 WARN,恒返回 0。

#!/usr/bin/env python3
# file: sim/ch07_flow_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/ch07_flow_bench.py --config config/ --out out/
"""B 基准:难度参数敏感性。

把 config/ch07_levels.json 里每一关的 difficulty 单独上下扰动 ±benchPerturbPct%,
重跑心流模型,看「心流占比」漂多少。软告警:漂移超阈值只打 WARN,恒返回 0。
产出:out/ch07_flow_bench.json
"""

from __future__ import annotations

import argparse
import json
import os

from ch07_difficulty_flow import load_levels, load_flow, simulate, summarize

WARN_FLOW_DRIFT_PERMILLE = 60  # 心流占比漂移超过 60‰ 就打软告警


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()

    levels = load_levels(args.config)
    flow = load_flow(args.config)
    pct = flow["benchPerturbPct"]
    base_diffs = [lv["difficulty"] for lv in levels["levels"]]
    base_flow = summarize(simulate(base_diffs, flow))["flowPermille"]

    cases = []
    for i, d in enumerate(base_diffs):
        for sign, tag in ((+1, "up"), (-1, "down")):
            perturbed = list(base_diffs)
            perturbed[i] = (d * (100 + sign * pct)) // 100
            fp = summarize(simulate(perturbed, flow))["flowPermille"]
            cases.append({"index": i + 1, "dir": tag, "difficulty": perturbed[i],
                          "flowPermille": fp, "driftPermille": fp - base_flow})

    drift_max = max(abs(c["driftPermille"]) for c in cases)
    os.makedirs(args.out, exist_ok=True)
    report = {
        "version": flow["version"],
        "baseFlowPermille": base_flow,
        "perturbPct": pct,
        "caseCount": len(cases),
        "maxAbsDriftPermille": drift_max,
        "warnThreshold": WARN_FLOW_DRIFT_PERMILLE,
        "withinTolerance": drift_max <= WARN_FLOW_DRIFT_PERMILLE,
        "cases": cases,
    }
    with open(os.path.join(args.out, "ch07_flow_bench.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)

    print(json.dumps({"baseFlowPermille": base_flow, "maxAbsDriftPermille": drift_max,
                      "withinTolerance": report["withinTolerance"]}, ensure_ascii=False))
    if not report["withinTolerance"]:
        print(f"WARN 难度扰动 {pct}% 后心流占比漂移 {drift_max}‰ > {WARN_FLOW_DRIFT_PERMILLE}")
    return 0  # B 层软告警:恒返回 0


if __name__ == "__main__":
    raise SystemExit(main())

跑 /opt/homebrew/bin/python3 sim/ch07_flow_bench.py --config config/ --out out/,输出(同机相对量级,绝对数值不可移植):

{"baseFlowPermille": 750, "maxAbsDriftPermille": 100, "withinTolerance": false}
WARN 难度扰动 25% 后心流占比漂移 100‰ > 60

怎么读这份敏感性

这条 WARN 不是失败,而是本节最重要的发现:心流占比对单关难度是敏感的。40 次扰动里,漂移只有三种取值——0、±50、±100(因为 20 关每翻一档就是 50‰)。多数扰动(改主线关)漂移为 0,说明模型对普通关不敏感;最大的 -100‰ 出现在把某一关难度下调 25% 时(L16 从 58 降到 43),它让这一关从焦虑翻进心流,同时经能力递推级联影响了后续几关的落档。

这带来两条实践结论:一是别用单关难度当调参旋钮,改一关会顺着能力递推影响下游,微调也要重跑整条曲线复核;二是敏感性是设计选择而非 bug——滞后追赶模型天生会把局部扰动放大到后续几关,这恰恰说明「难度是整体节奏问题,不是单点问题」。B 层用软告警(WARN 而非 FAIL)承认这一点:漂移 100‰ 值得看一眼,但不该阻断流程。

项值含义
基准心流占比750‰20 关里 15 关
扰动方式单关难度 ±25%40 个用例
最大漂移100‰2 关翻档
软阈值60‰超阈值打 WARN
结论WARN单关敏感,须整条复核

从「评估」到「反推」

7.1 给了结构,7.2 给了评估——但两节的难度值都还是手工填的:我们先填了 difficulty,再回头算它落不落在心流里。这在实际开发里是反的:我们往往先知道「第 1 关要让 88% 的玩家过关、最后一关压到 52%」,再倒推每一关的难度该设多少。7.3 就做这件事:给定目标通过率曲线,用一段 P 层内核反推难度参数,并带一个可播种的抖动生成器,把「填表」变成「生成 + 校验」。

阅读导航:上一节:关卡结构设计 · 下一节:关卡难度校准与自动生成 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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