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 | 玩家会不会把能量用满 |
| 挑战 | enemyAttack | 6..30,步长 1 | 敌人每回合的压迫强度 |
| 固定 | enemyHp | 60 | 排除「血厚」这一维,只留压迫 |
| 观测量 | 胜率千分比 | 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 | 带宽(挑战区间) | 带宽宽度 | 带宽中心 | 说明 |
|---|---|---|---|---|
| 1 | 7..8 | 2 | 7 | 出牌预算最小,容错最窄 |
| 2 | 14..17 | 4 | 15 | 能把能量用满,带宽加宽一倍 |
| 3 | 18..23 | 6 | 20 | 再加一张低费牌,带宽继续加宽 |
| 4 | 18..23 | 6 | 20 | 与 b=3 完全相同 |
| 5 | 18..23 | 6 | 20 | 与 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 心流带宽的可计算化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。