动手前照例划清边界。站内那篇小游戏开发综述 是引擎选型 / 性能 / 变现的单篇综述,它讲的是「用什么做、跑得快不快、怎么赚钱」;本节讲的是「做出来之后,玩家每一秒被打动几次」,属于核心循环的可运行拆解与可玩原型这一条线。全书写法是成体系 + 可运行:这里的每个数字都来自本机脚本,不是拍脑袋。
上一节我们把《合成小岛》的核心循环拆成了五个动作,算出整轮 12.1 秒、最短一圈 4.7 秒。但那只是结构——它告诉你时间花在哪,没告诉你这些时间「够不够爽」。这一节给循环装上两个仪表:循环时长和反馈密度。
循环时长:一局到底多长
先把「循环时长」这个词说准。它至少有三层含义,混用就会算错:
- 最短一圈(
roundMs):上一节的 4.7 秒,玩家感到一次完整闭环的最短路径。 - 整轮循环(
fullLoopMs):12.1 秒,把升级、解锁这类低频动作也算进去。 - 一次游戏会话:玩家打开游戏到退出,可能包含几十轮循环。
本节关心的是前两个,因为它们决定了反馈的节奏。一次会话多长,是留存与运营的事,本卷后面再谈。
一个常见的错误是拿「整轮循环」去评估新手体验。新玩家在前两分钟根本遇不到「解锁新地块」这种动作,他经历的全是最短一圈:点、拖、卖、点、拖、卖。所以评估第一印象要看 roundMs,评估长期节奏才看 fullLoopMs。两个数差了三倍,混用必然得出错误结论。
反馈密度:把「可感知反馈」数出来
「反馈密度」听起来玄,其实可以定义得很硬:单位时间内,玩家能感知到的反馈事件次数。关键是把「反馈事件」的边界划清——一次粒子爆发、一次音效、一次数字跳动、一次震动、一次进度条推进,都算一个事件;而「物品从 A 移到 B」这种没有情绪峰值的位移不算。
按这个口径,《合成小岛》每个动作的反馈事件数是这样的。下面这份是 D 层配置,它和上一节的 ch06_loop.json 共享同一批动作与时长,只多了一个 feedbackCount 字段。
{
"version": 1,
"game": "merge-island",
"note": "反馈密度模型:把循环里每一次可感知反馈(弹出/音效/震动/数字跳动/进度条)当作一个事件;事件总数 ÷ 循环时长 = 每秒反馈次数。心流区间用一个整数匹配函数刻画,峰值在 flowPeakPerSec。",
"flowPeakPerSec": 4,
"benchPerturbPct": 20,
"actions": [
{ "id": "collect", "name": "点按产出器", "durationMs": 1200, "feedbackCount": 3 },
{ "id": "merge", "name": "拖拽合成", "durationMs": 2600, "feedbackCount": 6 },
{ "id": "sell", "name": "交付出售", "durationMs": 900, "feedbackCount": 2 },
{ "id": "upgrade", "name": "升级产出器", "durationMs": 3200, "feedbackCount": 4 },
{ "id": "unlock", "name": "解锁新地块", "durationMs": 4200, "feedbackCount": 3 }
]
}
注意 merge(拖拽合成)给了 6 次反馈,是全表最高的:合成光效、音效、震动、新物品弹出、连击提示、经验条推进——核心爽点就该有最高的反馈密度。而 sell(交付出售)只有 2 次(金币飞入 + 数字跳动),因为它是过渡动作。反馈事件数的分配,本身就是一次设计决策,把它写进配置,就再也不能含糊。
S 层:把密度映射到心流曲线
有了 feedbackCount 和 durationMs,密度就是一次整数除法。但光有一个数字没用,还要知道它落在「太冷清」和「太吵」之间的哪个位置。这里用一个单峰匹配函数:密度越接近峰值,心流强度越高;偏离峰值(无论偏冷还是偏热)都会下降。下面这段是 S 层仿真脚本,全程整数运算,并画出这条曲线。
#!/usr/bin/env python3
# file: sim/ch06_feedback.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch06_feedback.py --config config/ --out out/
"""循环时长与反馈密度模型:把「每秒反馈次数」映射到一条单峰的心流曲线。
密度单位用「每秒反馈次数的十倍」(deci),全程整数运算:
densityDeci = feedbackSum * 10000 // totalMs
engagement = 200 * d * peak // (d*d + peak*peak) (d=peak 时取满 100)
产出:out/ch06_feedback.json + out/ch06_feedback_curve.csv + out/ch06_feedback.svg
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def load(cfg_dir):
with open(os.path.join(cfg_dir, "ch06_feedback.json"), encoding="utf-8") as f:
return json.load(f)
def density_deci(feedback_sum, total_ms):
"""每秒反馈次数 ×10(整数地板除)。"""
return (feedback_sum * 10000) // total_ms if total_ms else 0
def engagement(d, peak):
"""整数匹配函数:d=peak 时 100,越远离峰值越低。"""
denom = d * d + peak * peak
return (200 * d * peak) // denom if denom else 0
def draw_svg(path, peak, d_max, current_d, current_e):
W, H, PAD = 720, 400, 56
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": "26", "font-size": "15", "fill": "#111827"}).text = (
"反馈密度 → 心流强度(峰值 " + f"{peak / 10:.1f}" + " 次/秒)"
)
x0, x1, y0, y1 = PAD, W - 24, H - PAD, 46
sx = lambda d: x0 + (x1 - x0) * d / d_max
sy = lambda e: y1 + (y0 - y1) * (1 - e / 100)
for i in range(5):
y = y1 + (y0 - y1) * i / 4
ET.SubElement(svg, "line", {"x1": str(x0), "y1": f"{y:.1f}", "x2": str(x1), "y2": f"{y:.1f}",
"stroke": "#e5e7eb"})
ET.SubElement(svg, "text", {"x": str(x0 - 8), "y": f"{y + 4:.1f}", "font-size": "11",
"text-anchor": "end", "fill": "#6b7280"}).text = str(100 - 25 * i)
ET.SubElement(svg, "line", {"x1": str(x0), "y1": str(y0), "x2": str(x1), "y2": str(y0), "stroke": "#9ca3af"})
ET.SubElement(svg, "line", {"x1": str(x0), "y1": str(y1), "x2": str(x0), "y2": str(y0), "stroke": "#9ca3af"})
pts = " ".join(f"{sx(d):.2f},{sy(engagement(d, peak)):.2f}" for d in range(0, d_max + 1))
ET.SubElement(svg, "polyline", {"points": pts, "fill": "none", "stroke": "#2563eb", "stroke-width": "2"})
ET.SubElement(svg, "line", {"x1": f"{sx(current_d):.1f}", "y1": str(y1), "x2": f"{sx(current_d):.1f}",
"y2": str(y0), "stroke": "#dc2626", "stroke-dasharray": "4 4"})
ET.SubElement(svg, "circle", {"cx": f"{sx(current_d):.1f}", "cy": f"{sy(current_e):.1f}", "r": "5",
"fill": "#dc2626"})
ET.SubElement(svg, "text", {"x": f"{sx(current_d) + 8:.1f}", "y": f"{sy(current_e) - 8:.1f}",
"font-size": "12", "fill": "#dc2626"}).text = (
f"当前 {current_d / 10:.1f} 次/秒 → {current_e}"
)
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)
total_ms = sum(a["durationMs"] for a in cfg["actions"])
feedback_sum = sum(a["feedbackCount"] for a in cfg["actions"])
peak = cfg["flowPeakPerSec"] * 10
d = density_deci(feedback_sum, total_ms)
e = engagement(d, peak)
d_max = peak * 2
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.out, "ch06_feedback_curve.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["densityDeci", "densityPerSec", "engagement"])
for dd in range(0, d_max + 1):
w.writerow([dd, f"{dd / 10:.1f}", engagement(dd, peak)])
svg_bytes = draw_svg(os.path.join(args.out, "ch06_feedback.svg"), peak, d_max, d, e)
report = {
"version": cfg["version"],
"totalMs": total_ms,
"feedbackSum": feedback_sum,
"densityDeci": d,
"densityPerSecTimes10": d,
"flowPeakDeci": peak,
"engagement": e,
"svg": os.path.join(args.out, "ch06_feedback.svg"),
"svgBytes": svg_bytes,
}
with open(os.path.join(args.out, "ch06_feedback.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps({k: report[k] for k in ("totalMs", "feedbackSum", "densityDeci", "flowPeakDeci", "engagement")},
ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):
{"totalMs": 12100, "feedbackSum": 18, "densityDeci": 14, "flowPeakDeci": 40, "engagement": 62}
读法:整轮循环 12100ms,反馈事件共 18 个,所以密度是 18 × 10000 ÷ 12100 = 14(单位是「每秒反馈次数的十倍」,即 1.4 次/秒)。把它代入匹配函数,得到心流强度 62(满分 100)。同时落盘 out/ch06_feedback.svg——一条先升后降的单峰曲线,横轴是密度、纵轴是心流强度,红线标出当前落点。
单峰曲线:不是「反馈越多越好」
看到 62 分,第一反应往往是「那我把反馈事件加多不就上去了」。这正是要小心的直觉陷阱。匹配函数 200 · d · peak / (d² + peak²) 是单峰的:它在 d = peak(4.0 次/秒)处取满分 100,往两边都下降。也就是说——
- 密度太低(比如 0.5 次/秒),玩家觉得「点了没反应」,心流强度掉到 20 以下;
- 密度太高(比如 8 次/秒),反馈互相淹没,玩家反而「感觉不到哪个重要」,心流强度同样掉下来。
所以设计目标不是「把 62 提到 100」,而是判断 62 这个落点对不对。对《合成小岛》这种轻度合成放置来说,1.4 次/秒是偏「安静」的一档——它刻意留白,因为核心玩家(第 5 章定义的通勤碎片时间用户)不需要被持续轰炸。如果这是一款跑酷或射击,1.4 次/秒就太冷了,得往 3–4 次/秒推。
这里必须诚实标注:flowPeakPerSec = 4 是设计假设,不是从真实平台后台量出来的。真实的心流峰值需要 A/B 实验数据才能标定,那属于「需真实业务环境,命令行不可复现」的范畴。本节能确定的是曲线形状(单峰、可计算)与当前落点(1.4 次/秒 → 62 分)这两个可复现的结论。
B 层:时长参数的敏感性
密度是 1.4 次/秒、心流 62 分,但这两个数字依赖每个动作的 durationMs——而这些时长是估出来的,会飘。一个稳健的设计必须知道:时长估偏多少,结论会跟着漂多少。下面这段是 B 层基准脚本,把每个动作的时长单独上下扰动 benchPerturbPct(20%),看心流强度漂多少。B 层是软告警层:漂移超阈值只打 WARN,不判失败。
#!/usr/bin/env python3
# file: sim/ch06_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/ch06_bench.py --config config/ --out out/
"""B 基准:循环时长参数敏感性。
把 config/ch06_feedback.json 里每个动作的 durationMs 单独上下扰动 ±benchPerturbPct%,
看「反馈密度 → 心流强度」的落点漂多少。软告警:漂移超过阈值只打 WARN,不返回非 0。
产出:out/ch06_bench.json
"""
from __future__ import annotations
import argparse
import json
import os
from ch06_feedback import density_deci, engagement, load
WARN_ENGAGEMENT_DRIFT = 10 # 心流强度漂移超过 10 个点就打软告警
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)
peak = cfg["flowPeakPerSec"] * 10
pct = cfg["benchPerturbPct"]
base_total = sum(a["durationMs"] for a in cfg["actions"])
base_fb = sum(a["feedbackCount"] for a in cfg["actions"])
base_e = engagement(density_deci(base_fb, base_total), peak)
rows = []
for a in cfg["actions"]:
for sign, tag in ((+1, "up"), (-1, "down")):
total = base_total - a["durationMs"] + (a["durationMs"] * (100 + sign * pct)) // 100
d = density_deci(base_fb, total)
e = engagement(d, peak)
rows.append({"action": a["id"], "dir": tag, "totalMs": total,
"densityDeci": d, "engagement": e, "drift": e - base_e})
drift_max = max(abs(r["drift"]) for r in rows)
os.makedirs(args.out, exist_ok=True)
report = {
"version": cfg["version"],
"baseTotalMs": base_total,
"baseEngagement": base_e,
"perturbPct": pct,
"cases": rows,
"maxAbsDrift": drift_max,
"warnThreshold": WARN_ENGAGEMENT_DRIFT,
"withinTolerance": drift_max <= WARN_ENGAGEMENT_DRIFT,
}
with open(os.path.join(args.out, "ch06_bench.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps({"baseEngagement": base_e, "maxAbsDrift": drift_max,
"withinTolerance": report["withinTolerance"]}, ensure_ascii=False))
if not report["withinTolerance"]:
print(f"WARN 时长参数扰动 {pct}% 后心流强度漂移 {drift_max} > {WARN_ENGAGEMENT_DRIFT}")
return 0 # B 层软告警:恒返回 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):
{"baseEngagement": 62, "maxAbsDrift": 4, "withinTolerance": true}
逐个动作看落点(out/ch06_bench.json 里的 cases):
| 动作 | 方向 | 整轮时长 | 密度(×10) | 心流 | 漂移 |
|---|---|---|---|---|---|
| collect | +20% | 12340ms | 14 | 62 | 0 |
| collect | -20% | 11860ms | 15 | 65 | +3 |
| merge | +20% | 12620ms | 14 | 62 | 0 |
| merge | -20% | 11580ms | 15 | 65 | +3 |
| sell | +20% | 12280ms | 14 | 62 | 0 |
| sell | -20% | 11920ms | 15 | 65 | +3 |
| upgrade | +20% | 12740ms | 14 | 62 | 0 |
| upgrade | -20% | 11460ms | 15 | 65 | +3 |
| unlock | +20% | 12940ms | 13 | 58 | -4 |
| unlock | -20% | 11260ms | 15 | 65 | +3 |
结论很干净:任何单个动作的时长估偏 20%,心流强度最多漂 4 个点(62 → 58 或 65),远低于 10 点的告警线。这说明当前设计对时长误差不敏感——即使程序把某个动作的时长估错两成,心流落点依然稳定在「安静但不冷清」这一档。这正是 B 层存在的意义:它不告诉你设计对不对,它告诉你设计有多稳。
反过来说,如果扰动 20% 就漂了 30 个点,说明循环正卡在曲线的陡坡上,任何一点时长误差都会让体验剧烈变化——那才是真正该报警的状态。稳健的设计,应该落在曲线平缓的一段上。
落到设计上的三条规则
把这一节收成三条可以直接用的规则:
- 先分开量相位和密度。 相位回答「时间花在哪」(上一节),密度回答「这些时间被打动几次」(本节)。一个循环可以相位健康但密度很低,两者要分开调。
- 不要一味加反馈。 心流是单峰曲线,密度超过峰值后加得越多掉得越快。先判断当前落点偏冷还是偏热,再决定是加还是减。
- 给参数留敏感性余量。 时长是估出来的,必然有误差。用 B 层脚本量一下:扰动 20% 后结论还稳不稳?稳,说明设计有余量;不稳,说明卡在陡坡上,得先把落点移到平缓段。
下一节把这一节和上一节的结论落成一个能跑的原型:用 TypeScript 写合成内核(可播种、无 Math.random),打包成单文件 HTML,再用 Playwright 无头验证——让「核心循环」从一张表、一条曲线,变成一个浏览器里点得动的《合成小岛》。
阅读导航:上一节:一句话玩法与核心循环拆解 · 下一节:最小可玩原型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。