10.1 心流:挑战与技巧的匹配

心流常被讲成「挑战等于技巧」的一个点,但整数机制里没有那个点。本节把技巧轴落成每回合出牌预算、挑战轴落成敌人攻击强度,逐格跑出《灰烬牌库》的匹配图:每个技巧档的匹配是一段连续区间(带宽)而非单点,并如实报告技巧轴在能量上限处饱和、带宽不再右移。

10.1 心流:挑战与技巧的匹配

《小游戏开发实战》卷一 7.2 难度曲线与心流 给出的是 S 层心流单点模型:把挑战画成一条随时间上升的曲线,再在 B 层量「难度指标对参数有多敏感」——它的核心是一个点,某时刻的挑战值,以及它与技巧的差。本章换一个口径:匹配不是一个点,是一段区间。同一个技巧档,不是「只有一个挑战值恰好合适」,而是「一段挑战值都合适」;这段区间叫心流带宽。10.1 先把匹配落成两个整数轴并画出匹配图,10.2 把带宽的上下沿各钉到 ±1 并算它的位移与重叠,10.3 用带宽去排一条 12 关的难度曲线。这是本卷与那篇最本质的分工:那篇量一个点的斜率,本卷量一段区间的宽度。

匹配不是一个点

心流的经典说法是「挑战与技巧相当」。教科书常把它画成一张二维图:横轴技巧、纵轴挑战,中间一条对角线是「挑战 = 技巧」,线上就是心流通道。这条线是理想化的——它假设挑战与技巧都是连续量,且存在「恰好相等」的取值。

整数机制里没有这个「恰好」。敌人的攻击是整数、玩家的能量是整数、一回合能出的牌数是整数,技巧和挑战都只能取整数值。于是「挑战 = 技巧」要么无解,要么只在某个整数上偶然成立。可用的口径必须放宽:挑战与技巧的差落在一个可接受的范围内,就算匹配。这个范围就是带宽。

口径匹配的定义在整数机制里的问题
点模型挑战 = 技巧整数下常无解;相邻整数之间没有中间值
区间模型挑战 ∈ [技巧 − w, 技巧 + w]总有解;带宽 w 本身可测
本章口径代理胜率 ∈ [hard, easy]用可跑的观测量代替不可观测的感受

必须先把话说死:本节用的「匹配」是代理指标(proxy)。心流是主观体验,本机没有玩家、没有问卷、没有生理信号,任何数字都不能证明玩家进入了心流。这里能算的只有一个可观测的代理量——在固定策略与固定种子池下玩家赢下这一局的比例(胜率千分比)。它只用来做相对比较:哪个技巧档的带宽更宽、哪段挑战让胜率落在中间。绝对数值(比如「胜率 687‰」)依赖策略与种子池,不能读成「玩家有 68.7% 的概率感到心流」。

两个轴怎么落成整数

要把匹配图画出来,两个轴都得先变成整数。

技巧轴:用「每回合出牌预算」当技巧的代理。同一份 config/play_spec.json,玩家每回合能主动尝试出牌几次,就是技巧档 b。b 越小,玩家越不会打(明明还有能量却提前结束回合);b 越大,玩家越会把能量用满。它不是「玩家水平」本身,只是水平的一个可算代理。

挑战轴:用 enemyAttack 当挑战的代理(enemyHp 固定 60)。敌人每回合的压迫越强,玩家越容易在自己打死敌人之前先被打死,这一局的挑战就越高。

轴规格字段取值代理的是什么
技巧无(策略序列长度)b = 1..5玩家会不会把能量用满
挑战enemyAttack6..30,步长 1敌人每回合的压迫强度
固定enemyHp60排除「血厚」这一维,只留压迫
观测量胜率千分比0..1000匹配与否的代理读数

技巧档 b 对应的策略序列是 (p0 × b, e) 重复:每回合先尝试出牌 b 次(p0 打当前手牌第一张,费用不够会被 run_spec 跳过),再结束回合。b 越大,一回合里被真正打出的牌越多。这就是「技巧」在这一节里的全部含义——它只是把「出牌预算」当成水平的代理,不代表真实玩家的水平。

S 层:匹配图

S 层脚本 sim/gd_play_10_match.py 逐格跑 5 × 25 = 125 个格子,每格 16 个显式种子,共 2000 次对局;再把每格按胜率三分类:> 900‰ 是「太易」、< 250‰ 是「太难」、其余是「带宽内」。每个技巧档的带宽 = 该行里连续落在带宽内的最长一段挑战区间。全程整数、无浮点、无 random;匹配图用 ashlib 的 svg_begin/svg_text/svg_save 加自写的 <rect> 画成纯 xml.etree SVG。

{
  "id": "gd_play_10_match",
  "note": "卷二 10.1 心流匹配:技巧轴(每回合出牌预算 b=1..5)× 挑战轴(enemyAttack 6..30 步长 1),enemyHp 固定 60,显式种子池;三分类阈值(太易 / 带宽 / 太难)全整数千分比,禁浮点",
  "seedPool": [701, 703, 705, 707, 709, 711, 713, 715, 717, 719, 721, 723, 725, 727, 729, 731],
  "cellsPerScan": 16,
  "fixed": { "enemyHp": 60 },
  "skillLevels": [1, 2, 3, 4, 5],
  "challenge": { "param": "enemyAttack", "from": 6, "to": 30, "step": 1 },
  "window": { "easyPermille": 900, "hardPermille": 250 },
  "turnBudget": 26
}
#!/usr/bin/env python3
# file: sim/gd_play_10_match.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_10_match.py
"""卷二 10.1 心流:挑战与技巧的匹配(S 层)——把「匹配」画成一张二维分类图。

技巧轴 = 每回合出牌预算 b(1..5,玩家「会不会打」的代理量);挑战轴 = enemyAttack
(敌人每回合的压迫强度,enemyHp 固定 60)。逐格跑显式种子池得到胜率千分比,再把每格
按胜率三分类:> easyPermille 为「太易」、< hardPermille 为「太难」、其余为「带宽内」。
分类给出每个技巧档的**带宽**(挑战轴上连续落在带宽内的格子),而不是一个匹配点。
全程整数、无浮点、无 random;胜率是代理指标,只做相对比较。产出 out/play10_match.json
+ out/play10_match.svg。
"""
import copy
import json
import os
import sys
import time
import xml.etree.ElementTree as ET

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
CONFIG = os.path.join(ROOT, "config/gd_play_10_match.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402

CLASS_FILL = {"easy": "#38bdf8", "band": "#22c55e", "hard": "#ef4444"}


def seq(budget: int, turns: int) -> list:
    """技巧档 b 的策略序列:每回合先尝试 b 次出牌,再结束回合。"""
    return (["p0"] * budget + ["e"]) * turns


def win_permille(spec, over, budget, seeds, turns):
    wins = 0
    for sd in seeds:
        s = copy.deepcopy(spec)
        s["seed"] = sd
        s["params"].update(over)
        wins += 1 if ashlib.run_spec(s, seq(budget, turns))["won"] else 0
    return 1000 * wins // len(seeds)


def classify(v, easy, hard):
    if v > easy:
        return "easy"
    if v < hard:
        return "hard"
    return "band"


def band_of(row, easy, hard):
    """返回挑战轴上连续落在带宽内的闭区间下标;不连续时只取最长的一段。"""
    runs, cur = [], []
    for i, v in enumerate(row):
        if hard <= v <= easy:
            cur.append(i)
        else:
            if cur:
                runs.append(cur)
            cur = []
    if cur:
        runs.append(cur)
    if not runs:
        return None
    best = max(runs, key=len)
    return (best[0], best[-1])


def draw(skills, chs, grid, bands, easy, hard, path):
    cw, chh, x0, y0 = 34, 30, 96, 74
    root = ashlib.svg_begin(x0 + cw * len(chs) + 44, y0 + chh * len(skills) + 74)
    ashlib.svg_text(root, 16, 30, "心流匹配图:技巧档(行)× 挑战 enemyAttack(列)", 15)
    ashlib.svg_text(root, 16, 50, f"绿=带宽内 [{hard},{easy}]‰  蓝=太易  红=太难", 12, "#94a3b8")
    for i, b in enumerate(skills):
        ashlib.svg_text(root, 84, y0 + i * chh + chh - 10, f"b={b}", 13, anchor="end")
        for j in range(len(chs)):
            cls = classify(grid[i][j], easy, hard)
            ET.SubElement(root, "rect", {
                "x": str(x0 + j * cw + 1), "y": str(y0 + i * chh + 1),
                "width": str(cw - 2), "height": str(chh - 2), "fill": CLASS_FILL[cls],
                "data-win": str(grid[i][j])})
        bd = bands[i]
        if bd:
            ET.SubElement(root, "rect", {
                "x": str(x0 + bd[0] * cw), "y": str(y0 + i * chh),
                "width": str((bd[1] - bd[0] + 1) * cw), "height": str(chh),
                "fill": "none", "stroke": "#f8fafc", "stroke-width": "2"})
    for j, c in enumerate(chs):
        if c % 2 == 0:
            ashlib.svg_text(root, x0 + j * cw + cw // 2, y0 + chh * len(skills) + 18,
                            str(c), 11, "#94a3b8", "middle")
    ashlib.svg_text(root, x0 + cw * len(chs) // 2, y0 + chh * len(skills) + 44,
                    "challenge: enemyAttack(enemyHp=60 固定)", 12, "#94a3b8", "middle")
    return ashlib.svg_save(root, path)


def main():
    t0 = time.perf_counter_ns()
    spec = ashlib.load_spec()
    with open(CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    seeds = cfg["seedPool"][: cfg["cellsPerScan"]]
    skills = cfg["skillLevels"]
    ch = cfg["challenge"]
    chs = list(range(ch["from"], ch["to"] + 1, ch["step"]))
    easy, hard = cfg["window"]["easyPermille"], cfg["window"]["hardPermille"]
    turns = cfg["turnBudget"]

    grid = [[win_permille(spec, {**cfg["fixed"], ch["param"]: c}, b, seeds, turns) for c in chs]
            for b in skills]
    bands = [band_of(row, easy, hard) for row in grid]
    table = []
    for i, b in enumerate(skills):
        bd = bands[i]
        table.append({
            "skill": b, "bandIndex": list(bd) if bd else None,
            "bandLo": chs[bd[0]] if bd else None, "bandHi": chs[bd[1]] if bd else None,
            "bandWidth": (bd[1] - bd[0] + 1) if bd else 0,
            "bandCenter": (chs[bd[0]] + chs[bd[1]]) // 2 if bd else None})
    elapsed_ms = (time.perf_counter_ns() - t0) // 1000000
    report = {"id": "gd_play_10_match", "seeds": len(seeds), "challengeParam": ch["param"],
              "challenges": chs, "skills": skills, "fixed": cfg["fixed"], "window": cfg["window"],
              "grid": grid, "bands": table, "runs": len(skills) * len(chs) * len(seeds),
              "elapsedMs": elapsed_ms}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "play10_match.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    size = draw(skills, chs, grid, bands, easy, hard, os.path.join(OUT, "play10_match.svg"))

    print(f"skills={skills}  challenges={chs[0]}..{chs[-1]}  seeds={len(seeds)}  runs={report['runs']}")
    print(f"{'b\\atk':>6}" + "".join(f"{c:>5}" for c in chs))
    for i, b in enumerate(skills):
        print(f"{b:>6}" + "".join(f"{grid[i][j]:>5}" for j in range(len(chs))))
    print(f"window: easy>{easy}‰  band[{hard},{easy}]‰  hard<{hard}‰")
    for r in table:
        print(f"skill b={r['skill']}  band={r['bandLo']}..{r['bandHi']}  width={r['bandWidth']}"
              f"  center={r['bandCenter']}")
    print(f"elapsed={elapsed_ms} ms  svg={size} bytes")
    print("wrote out/play10_match.json out/play10_match.svg")
    ET.parse(os.path.join(OUT, "play10_match.svg"))
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/gd_play_10_match.py(绝对数值不可移植,同机相对量级可信):

skills=[1, 2, 3, 4, 5]  challenges=6..30  seeds=16  runs=2000
 b\atk    6    7    8    9   10   11   12   13   14   15   16   17   18   19   20   21   22   23   24   25   26   27   28   29   30
     1 1000  687  500  187    0    0    0    0    0    0    0    0    0    0    0    0    0    0    0    0    0    0    0    0    0
     2 1000 1000 1000 1000 1000 1000 1000 1000  625  625  625  375  125  125  125  125    0    0    0    0    0    0    0    0    0
     3 1000 1000 1000 1000 1000 1000 1000 1000 1000 1000 1000 1000  812  812  812  812  312  312   62   62   62   62   62   62   62
     4 1000 1000 1000 1000 1000 1000 1000 1000 1000 1000 1000 1000  812  812  812  812  375  375   62   62   62   62   62   62   62
     5 1000 1000 1000 1000 1000 1000 1000 1000 1000 1000 1000 1000  812  812  812  812  375  375   62   62   62   62   62   62   62
window: easy>900‰  band[250,900]‰  hard<250‰
skill b=1  band=7..8  width=2  center=7
skill b=2  band=14..17  width=4  center=15
skill b=3  band=18..23  width=6  center=20
skill b=4  band=18..23  width=6  center=20
skill b=5  band=18..23  width=6  center=20
elapsed=55 ms  svg=13285 bytes
wrote out/play10_match.json out/play10_match.svg

读图:匹配是一段区间

表里第一行就是结论:技巧档 b=1 时,enemyAttack 从 6 到 9 的胜率是 1000 → 687 → 500 → 187——没有一个「恰好匹配」的整数。挑战 6 太易(稳赢)、挑战 9 太难(多半会输),真正落在中间的是 7 和 8 两个整数。如果按点模型找「挑战 = 技巧」,这里根本找不到那个点;按区间模型,答案是 [7, 8] 这一段。

技巧档 b带宽(挑战区间)带宽宽度带宽中心说明
17..827出牌预算最小,容错最窄
214..17415能把能量用满,带宽加宽一倍
318..23620再加一张低费牌,带宽继续加宽
418..23620与 b=3 完全相同
518..23620与 b=3 完全相同

读这张表有三件事要分开说。第一,带宽随技巧加宽:b 从 1 到 3,宽度从 2 涨到 6——技巧越高,可接受的挑战范围越宽。第二,带宽随技巧右移:中心从 7 移到 20,技巧越高,合适的挑战越硬。第三,技巧轴在 b=3 饱和:b=4、b=5 的整行读数与 b=3 几乎逐格相同(只在 enemyAttack=22 处差一格,312 对 375)。原因是 energyMax=3——一回合最多把 3 点能量换成 3 张费用 1 的牌,出牌预算超过 3 之后,多出来的尝试只会撞上能量上限,不再转化为胜率。

第三条不是本节要解决的问题,但它对 10.3 有直接影响:难度设计不能假设技巧可以无限增长。当技巧撞上机制上限,带宽就不再右移,再往上堆挑战只会把玩家推进「太难」区。

自检表

检查项合格标准
两轴都是整数技巧 = 出牌预算 b,挑战 = enemyAttack,无浮点
匹配落成区间每个技巧档给出一段带宽,而非一个点
种子写死16 个种子在配置里显式列出,不是「1..16」的生成式描述
代理指标声明胜率写明是 proxy,只做相对比较,不写成「玩家感到心流」
饱和如实报告b=4、b=5 与 b=3 重复,不掩盖
产出可校验JSON + SVG 落盘,SVG 可被 ET.parse 解析

匹配图画出来是一块块绿色区域,不是一个点。但「绿色区域有多宽」这张图只给了个大概——边界画在格子上,格子宽 1,所以边可能偏一格。下一节用粗扫 + 整数二分把每个带宽的上下沿各钉到 ±1,再算带宽的位移、加宽与相邻档之间的重叠。

阅读导航:上一节:9.3 情绪与节奏的对齐 · 下一节:10.2 心流带宽的可计算化 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

  1. 附录 A 品类参数表、机制距离矩阵与数据集索引
  2. 9.3 自走棋的涌现
  3. 9.2 自走棋的经济与平衡