12.1 原型范围与验证目标

把玩法原型从「做一个演示」拉回「验证假设」:用 D 层配置声明原型范围、验证目标与指标注册表,用 S 层门禁脚本断言每个验证目标都绑定可测指标、北极星恰好一个、区间型目标上下界有序,让做什么、不做什么、拿什么指标验收都在动手写代码前先固化下来。

先划边界。站内的 PRD 系列 产出的是需求文档——它描述「要做什么功能、字段长什么样、流程怎么走」;本章的原型是可跑、可调参、可断言、可浏览器打开的工程。PRD 回答「产品应该长什么样」,本章回答「这个玩法到底值不值得往下做」:前者是纸面约定,后者必须能在机器上跑出数字来。前两章我们已经把《合成小岛》的核心循环落成了一个能跑的最小原型(见最小可玩原型 ),也做了立项决策与止损线;这一章要解决的是在它上面做验证与迭代。

原型要回答的不是「能不能做」而是「值不值得做」

第 6 章的原型证明了「核心循环跑得起来」。但「跑得起来」和「值得往下做」是两回事。一个能跑的原型可以毫无乐趣:玩家点两下就退出、单局短到没有节奏、没人愿意再来一局。原型的价值不在于演示,而在于用最小成本证伪一个假设。

所以原型的第一件事不是写代码,而是把范围钉死。范围失控是原型最常见的死法:一开始说「就验证合成手感」,三天后变成「顺便把登录、排行榜、内购也搭一下吧」——最后原型变成了半个产品,却一个问题都没回答。一个没有边界的原型,等于一个没有结论的实验。

要把范围钉死,只需要回答三个问题:

  1. 做什么:这一次原型要覆盖的最小能力集合是什么?
  2. 不做什么:哪些东西明确不碰,即使它们看起来顺手?
  3. 每个验证目标对应什么指标:每个目标必须绑定一个可测的数字,否则它就不是目标,是愿望。

第三点最容易被跳过,也最致命。如果目标是「验证玩法有趣」,那它永远无法被证伪,因为没有哪个数字能定义「有趣」。可测的写法是「首局完成率 ≥ 500 千分」「单局时长落在 90–240 秒」。指标是把假设变成可判定命题的那一步。

D 层:原型范围与验证目标表

下面这张表是本章的「一份 config」——落在 config/ch12_prototype.json。它把上面三个问题固化成机器可读的结构:inScope / outOfScope 是边界,metrics 是指标注册表(每个指标声明单位与来源),goals 是验证目标,每个目标用 metric 字段绑定到注册表里的一个指标。注意全部是整数:占比用千分(per-mille),时长用秒,没有任何浮点。

{
  "version": 1,
  "game": "merge-island",
  "prototype": "merge-core-0.1",
  "goal": "用最小代码验证「拖拽合成 + 产出金币 + 再投入升级」的核心循环是否成立,不验证完整游戏。",
  "inScope": [
    "合成内核:产出 / 拖拽合成 / 等级上限 / 金币奖励",
    "一次可玩会话:从空棋盘到第一局结束",
    "单机可播种随机:同一 seed 可逐位回放",
    "一个浏览器可打开的单文件页面 + 无头自动验证"
  ],
  "outOfScope": [
    "账号、云存档与跨端同步",
    "内购与广告 SDK 接入",
    "社交、好友与排行榜",
    "美术与音效成品资源",
    "多关卡、关卡编辑器与关卡数值"
  ],
  "metrics": [
    { "id": "firstRunCompletionPerMille", "unit": "per-mille", "source": "playtest", "desc": "完成第一局的测试者占比(千分)" },
    { "id": "meanSessionSec", "unit": "second", "source": "playtest", "desc": "一次会话的平均时长(秒)" },
    { "id": "replayPerMille", "unit": "per-mille", "source": "playtest", "desc": "至少重开一局的测试者占比(千分)" },
    { "id": "softlockCount", "unit": "count", "source": "replay", "desc": "确定性回放中出现的死锁次数" }
  ],
  "goals": [
    { "id": "g1", "name": "核心循环可理解", "metric": "firstRunCompletionPerMille", "direction": "up", "threshold": 500, "northStar": true },
    { "id": "g2", "name": "单局时长落在心流区间", "metric": "meanSessionSec", "direction": "range", "threshold": 0, "lower": 90, "upper": 240, "northStar": false },
    { "id": "g3", "name": "愿意再来一局", "metric": "replayPerMille", "direction": "up", "threshold": 400, "northStar": false },
    { "id": "g4", "name": "回放无死锁", "metric": "softlockCount", "direction": "down", "threshold": 0, "northStar": false }
  ]
}

这张表里有几个刻意的设计。第一,goals 里的每个目标都通过 metric 指向注册表,而不是内联一个字符串——这样「目标引用的指标必须真实存在」就是一条可校验的约束。第二,每个目标带 direction:up 表示越大越好(完成率、重玩率),down 表示越小越好(死锁次数),range 表示落在区间内(单局时长)。第三,g1 被标为 northStar(北极星)——它是这一版原型唯一不能失败的目标:如果连「核心循环可理解」都不成立,后面所有微调都没有意义。北极星是止损线的判据,这一点在 12.3 会变成可执行的代码。

S 层:范围门禁脚本

配置写好了,怎么保证它不会悄悄退化?答案是门禁脚本。下面这段是 S 层脚本,读 config/ch12_prototype.json,逐条断言「每个验证目标都有可测指标」。它的核心检查有五条:边界非空且互不相交、每个目标字段齐全、每个目标的指标必须在注册表里且注册表声明了单位与来源、北极星恰好一个、range 型目标的上下界必须有序。任何一条不满足即非零退出。

# file: sim/ch12_scope_guard.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch12_scope_guard.py --config config/ --out out/
"""原型范围与验证目标门禁:断言「每个验证目标都有可测指标」。

规则(任一违反即非零退出):
  1. inScope / outOfScope 均非空,且两者不相交;
  2. 每个 goal 必须有 id / name / metric / direction / threshold;
  3. 每个 goal 的 metric 必须在 metrics 注册表里,注册表声明了 unit 与 source;
  4. 有且只有一个 northStar;
  5. direction=range 的 goal 必须 lower < upper。

通过则产出 out/ch12_scope.json + out/ch12_scope.csv + out/ch12_scope.svg。
"""

from __future__ import annotations

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

REQUIRED_GOAL = ("id", "name", "metric", "direction", "threshold")


def load(cfg_dir: str) -> dict:
    with open(os.path.join(cfg_dir, "ch12_prototype.json"), encoding="utf-8") as f:
        return json.load(f)


def check(cfg: dict) -> list[str]:
    errs: list[str] = []
    scope = cfg.get("inScope", [])
    out = cfg.get("outOfScope", [])
    if not scope:
        errs.append("inScope 为空:原型必须至少声明一项做什么")
    if not out:
        errs.append("outOfScope 为空:原型必须至少声明一项不做什么")
    overlap = sorted(set(scope) & set(out))
    if overlap:
        errs.append(f"做什么与不做什么相交:{overlap}")

    registry = {m["id"]: m for m in cfg.get("metrics", [])}
    if not registry:
        errs.append("metrics 注册表为空:没有任何可测指标")

    goals = cfg.get("goals", [])
    if not goals:
        errs.append("goals 为空:原型必须至少有一个验证目标")

    north = 0
    for g in goals:
        for key in REQUIRED_GOAL:
            if key not in g:
                errs.append(f"目标 {g.get('id', '?')} 缺字段 {key}")
        mid = g.get("metric")
        if mid not in registry:
            errs.append(f"目标 {g.get('id', '?')} 的指标 {mid} 不在注册表里(不可测)")
        else:
            if not registry[mid].get("unit"):
                errs.append(f"指标 {mid} 未声明 unit")
            if not registry[mid].get("source"):
                errs.append(f"指标 {mid} 未声明 source")
        if g.get("direction") == "range":
            lo, hi = g.get("lower", 0), g.get("upper", 0)
            if not (lo < hi):
                errs.append(f"目标 {g.get('id', '?')} 是 range 但 lower({lo}) 不小于 upper({hi})")
        if g.get("northStar") is True:
            north += 1
    if north != 1:
        errs.append(f"northStar 目标应恰好一个,实为 {north}")

    return errs


def draw_svg(path: str, scope: list[str], out: list[str]) -> int:
    W = 760
    row_h = 26
    top = 64
    H = top + row_h * max(len(scope), len(out)) + 28
    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": "20", "y": "30", "font-size": "16", "fill": "#111827"}).text = \
        "原型范围:做什么(左) / 不做什么(右)"
    col_x = (20, 400)
    heads = ("做什么 inScope", "不做什么 outOfScope")
    for x, h in zip(col_x, heads):
        ET.SubElement(svg, "text", {"x": str(x), "y": "54", "font-size": "13",
                                    "font-weight": "bold", "fill": "#2563eb"}).text = h
    for col, items in enumerate((scope, out)):
        for i, item in enumerate(items):
            y = top + row_h * i
            ET.SubElement(svg, "text", {"x": str(col_x[col]), "y": str(y + 12), "font-size": "12",
                                        "fill": "#374151"}).text = f"{i + 1}. {item}"
    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)
    errs = check(cfg)
    if errs:
        for e in errs:
            print(f"FAIL {e}")
        print(f"== 原型范围门禁 FAIL {len(errs)}")
        return 1

    registry = {m["id"]: m for m in cfg["metrics"]}
    rows = []
    for g in cfg["goals"]:
        m = registry[g["metric"]]
        rows.append({
            "goal": g["id"], "name": g["name"], "metric": g["metric"],
            "direction": g["direction"], "threshold": g["threshold"],
            "unit": m["unit"], "source": m["source"], "northStar": bool(g.get("northStar")),
        })

    os.makedirs(args.out, exist_ok=True)
    with open(os.path.join(args.out, "ch12_scope.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
        w.writeheader()
        w.writerows(rows)

    svg_bytes = draw_svg(os.path.join(args.out, "ch12_scope.svg"), cfg["inScope"], cfg["outOfScope"])
    report = {
        "version": cfg["version"],
        "prototype": cfg["prototype"],
        "inScope": len(cfg["inScope"]),
        "outOfScope": len(cfg["outOfScope"]),
        "metrics": len(registry),
        "goals": rows,
        "northStar": next(r["goal"] for r in rows if r["northStar"]),
        "allGoalsMeasurable": True,
        "svg": os.path.join(args.out, "ch12_scope.svg"),
        "svgBytes": svg_bytes,
    }
    with open(os.path.join(args.out, "ch12_scope.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)

    print(json.dumps({"inScope": report["inScope"], "outOfScope": report["outOfScope"],
                      "metrics": report["metrics"], "goals": len(rows),
                      "northStar": report["northStar"]}, ensure_ascii=False))
    print("== 原型范围门禁 PASS:每个验证目标都有可测指标")
    return 0


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

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

{"inScope": 4, "outOfScope": 5, "metrics": 4, "goals": 4, "northStar": "g1"}
== 原型范围门禁 PASS:每个验证目标都有可测指标

门禁通过,说明四项目标都绑定了注册表里的可测指标、北极星恰好一个、range 型目标的上下界有序。脚本同时产出 out/ch12_scope.csv(目标清单)和 out/ch12_scope.svg(做什么 / 不做什么的两栏图)。这份 CSV 就是后面 12.3 迭代决策要消费的目标表——同一份目标定义,从范围门禁一路用到决策内核。

范围门禁拦住的三类跑偏

第一类是边界相交。有人会在 inScope 写「合成内核」,又在 outOfScope 写「合成特效」——听着像不冲突,但「合成内核」如果不含特效,那特效该谁做?门禁用集合交集把这类含糊直接顶出来,逼你重新切分。

第二类是指标不可测。目标是「验证合成爽感」,metric 写了个注册表里没有的 feelScore。门禁会报「指标不在注册表里」。这条约束的意义是:不能凭空发明一个指标名,必须先在注册表里定义它的单位与来源。一个没有单位、没有来源的指标,和「有趣」一样不可证伪。

第三类是北极星缺位或泛滥。一个都没有,说明你没想清楚哪条是底线;冒出三个,说明你什么都想要,等于没有优先级。门禁强制「恰好一个」,把优先级这件事变成一条硬约束。

把范围钉死之后,下一节要解决的是:这些指标到底怎么测、测出来的数字有多大把握。范围门禁保证「测什么」是清楚的,12.2 保证「怎么测」是可复现的。

阅读导航:上一节:立项决策与止损线 · 下一节:可玩性指标与测试方法 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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