6.2 循环时长与反馈密度

给核心循环装上时钟与密度:用配置把每一次可感知反馈数成一个事件,算出《合成小岛》每秒反馈次数,再用整数匹配函数把它映射到一条单峰心流曲线,并用 B 层基准做循环时长参数的敏感性分析,讲清为什么反馈不是越多越好、以及怎么判断当前落点偏冷还是偏热。

动手前照例划清边界。站内那篇小游戏开发综述 是引擎选型 / 性能 / 变现的单篇综述,它讲的是「用什么做、跑得快不快、怎么赚钱」;本节讲的是「做出来之后,玩家每一秒被打动几次」,属于核心循环的可运行拆解与可玩原型这一条线。全书写法是成体系 + 可运行:这里的每个数字都来自本机脚本,不是拍脑袋。

上一节我们把《合成小岛》的核心循环拆成了五个动作,算出整轮 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%12340ms14620
collect-20%11860ms1565+3
merge+20%12620ms14620
merge-20%11580ms1565+3
sell+20%12280ms14620
sell-20%11920ms1565+3
upgrade+20%12740ms14620
upgrade-20%11460ms1565+3
unlock+20%12940ms1358-4
unlock-20%11260ms1565+3

结论很干净:任何单个动作的时长估偏 20%,心流强度最多漂 4 个点(62 → 58 或 65),远低于 10 点的告警线。这说明当前设计对时长误差不敏感——即使程序把某个动作的时长估错两成,心流落点依然稳定在「安静但不冷清」这一档。这正是 B 层存在的意义:它不告诉你设计对不对,它告诉你设计有多稳。

反过来说,如果扰动 20% 就漂了 30 个点,说明循环正卡在曲线的陡坡上,任何一点时长误差都会让体验剧烈变化——那才是真正该报警的状态。稳健的设计,应该落在曲线平缓的一段上。

落到设计上的三条规则

把这一节收成三条可以直接用的规则:

  1. 先分开量相位和密度。 相位回答「时间花在哪」(上一节),密度回答「这些时间被打动几次」(本节)。一个循环可以相位健康但密度很低,两者要分开调。
  2. 不要一味加反馈。 心流是单峰曲线,密度超过峰值后加得越多掉得越快。先判断当前落点偏冷还是偏热,再决定是加还是减。
  3. 给参数留敏感性余量。 时长是估出来的,必然有误差。用 B 层脚本量一下:扰动 20% 后结论还稳不稳?稳,说明设计有余量;不稳,说明卡在陡坡上,得先把落点移到平缓段。

下一节把这一节和上一节的结论落成一个能跑的原型:用 TypeScript 写合成内核(可播种、无 Math.random),打包成单文件 HTML,再用 Playwright 无头验证——让「核心循环」从一张表、一条曲线,变成一个浏览器里点得动的《合成小岛》。

阅读导航:上一节:一句话玩法与核心循环拆解 · 下一节:最小可玩原型 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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