8.1 成长线与数值曲线

把《合成小岛》的成长线拆成升级成本与产出速率两条整数比递推曲线:用 S 层脚本算出每级耗时、累计投入与满级总账,讲清成本增速与产出增速的比值才是成长线真正的节奏旋钮,并与站点既有的通用经济配置逐级对照,最后收成三条可落地的设计规则,全程整数递推、命令行可复现。

照例先划边界。站内那篇通用经济框架 讲的是放置类游戏的通用资源产出、消耗与通胀控制——它给的是一套可复用的经济骨架,回答「经济该怎么设计」;本章给的是小游戏成长曲线与离线收益的可跑模型,落到《合成小岛》的具体等级表、可复现的整数递推脚本,以及一个浏览器里点得动的原型上。那篇是通用框架,本章是「这条成长线具体该长什么样、怎么算出来、怎么验证」。

第 7 章把《合成小岛》的关卡难度铺成了一条心流曲线。但关卡只解决「这一关难不难」,不解决「为什么玩家明天还想来」。让玩家明天还想来的,是成长线:等级在涨、产出在涨、地图在扩,玩家能感觉到自己变强了。这一节就把成长线从一句感觉,变成一张能算、能改、能对照的表。

成长线到底在成长什么

「成长线」这个词很容易被含糊地使用。在《合成小岛》里,它其实是两条曲线的赛跑:

  • 成本曲线:从第 L 级升到第 L+1 级,需要攒多少金币。它随等级递增。
  • 产出曲线:玩家在第 L 级每分钟能产多少金币。它通常也随等级递增。

玩家感受到的「升级越来越慢」,本质是成本涨得比产出快。如果成本涨得和产出一样快,那每一级的耗时就是恒定的——玩家永远用同样的时间去换下一级,成长感被抹平。所以成长线的节奏,不取决于成本涨多少,也不取决于产出涨多少,而取决于两者的比值。

把这个比值写成公式,就是本章唯一需要记住的一条:

每级耗时增速 = 成本增速 ÷ 产出增速

成本增速 1.40、产出增速 1.15,耗时增速就是 1.40 / 1.15 ≈ 1.22——每一级比上一级多花约 22% 的时间。这个数字才是「成长线陡不陡」的真正旋钮。后面所有的设计讨论,都会回到它。

D 层:把成长线写成配置

下面这份是 D 层配置,落在 config/ch08_progression.json。它刻意用整数比表达两条曲线:成本按 costNum/costDen = 140/100 递增,产出按 rateNum/rateDen = 115/100 递增。配置里不出现任何浮点——这是全书的硬规矩,因为同一份数字要被 Python 仿真、TS 内核、浏览器原型三处消费,浮点会在跨语言时产生最后一位的偏差。

{
  "version": 1,
  "game": "merge-island",
  "note": "成长线配置:等级 → 升级成本与产出速率。全部整数比递推,禁浮点。成本按 costNum/costDen 递增,产出按 rateNum/rateDen 递增;成本增速 ÷ 产出增速 = 每级耗时的增速,是成长线唯一的节奏旋钮。",
  "maxLevel": 12,
  "baseCost": 60,
  "costNum": 140,
  "costDen": 100,
  "baseRatePerMinute": 12,
  "rateNum": 115,
  "rateDen": 100,
  "unlockEveryLevels": 3
}

几个字段值得单独说:baseCost = 60 是 1 级升 2 级的金币门槛;baseRatePerMinute = 12 是 1 级的每分钟产出,它和站点既有的 config/economy.json 里的 baseOutputPerMinute = 12 完全一致,这不是巧合——两套配置建模的是同一款游戏的同一条产出线,只是本章多补了成本侧。unlockEveryLevels = 3 表示每 3 级解锁一次新地块,它是成长线上「看得见的里程碑」。

S 层:算出成本与每级耗时

有了配置,下面这段 S 层仿真脚本读 config/ 跑出整条成长线。它做三件事:用 BigInt 等价的整数幂算成本与产出、用整数上取整算每级耗时、顺手读一眼 config/economy.json 做逐级对照。

#!/usr/bin/env python3
# file: sim/ch08_progression.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch08_progression.py --config config/ --out out/
"""成长线模型:等级 → 升级成本、产出速率、每级耗时。全程整数运算。

  cost(L)    = baseCost * costNum^(L-1) // costDen^(L-1)      升到 L+1 需要的金币
  rate(L)    = baseRate * rateNum^(L-1) // rateDen^(L-1)      在 L 级的每分钟产出
  minutes(L) = ceil(cost(L) / rate(L)) = (cost + rate - 1) // rate
  cumCost(L) = cost(1) + ... + cost(L)                        到 L 级前的累计投入

成本增速 ÷ 产出增速就是「每级耗时」的增速,这是成长线唯一的节奏旋钮:
  costNum/rateNum = 140/115 ≈ 1.22  →  越往后每级越久。
另读 config/economy.json 做对照(那份只建模产出增长,没有独立的成本增速)。
产出:out/ch08_progression.json + out/ch08_progression.csv + out/ch08_progression.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, "ch08_progression.json"), encoding="utf-8") as f:
        return json.load(f)


def load_reference(cfg_dir):
    """economy.json 是主控拥有的通用放置经济配置,只读、只做对照。"""
    path = os.path.join(cfg_dir, "economy.json")
    if not os.path.exists(path):
        return None
    with open(path, encoding="utf-8") as f:
        return json.load(f)


def cost_at(level, cfg):
    """升到 level+1 所需金币;level<1 返回 0。整数地板除。"""
    if level < 1:
        return 0
    return (cfg["baseCost"] * cfg["costNum"] ** (level - 1)) // (cfg["costDen"] ** (level - 1))


def rate_at(level, cfg):
    """在 level 级的每分钟产出;level<1 返回 0。整数地板除。"""
    if level < 1:
        return 0
    return (cfg["baseRatePerMinute"] * cfg["rateNum"] ** (level - 1)) // (
        cfg["rateDen"] ** (level - 1)
    )


def minutes_at(level, cfg):
    """在 level 级攒够升级金币所需分钟数,上取整(不用浮点 ceil)。"""
    rate = rate_at(level, cfg)
    if rate <= 0:
        return 0
    return (cost_at(level, cfg) + rate - 1) // rate


def curve(cfg):
    rows, cum = [], 0
    for lv in range(1, cfg["maxLevel"] + 1):
        c = cost_at(lv, cfg)
        r = rate_at(lv, cfg)
        cum += c
        rows.append(
            {
                "level": lv,
                "costToNext": c,
                "ratePerMinute": r,
                "minutesToNext": minutes_at(lv, cfg),
                "cumCost": cum,
                "unlock": "是" if lv % cfg["unlockEveryLevels"] == 0 else "",
            }
        )
    return rows


def reference_check(cfg, ref):
    """与 economy.json 的对照:同 baseRate 下,两条产出曲线的差在哪一级拉开。"""
    if not ref:
        return None
    eco = ref["economy"]
    max_lv = min(cfg["maxLevel"], eco["maxLevel"])
    rows = []
    for lv in range(1, max_lv + 1):
        a = rate_at(lv, cfg)
        b = (eco["baseOutputPerMinute"] * eco["levelBonusNum"] ** (lv - 1)) // (
            eco["levelBonusDen"] ** (lv - 1)
        )
        rows.append({"level": lv, "ch08Rate": a, "economyRate": b, "diff": a - b})
    return {
        "source": "config/economy.json",
        "baseOutputPerMinute": eco["baseOutputPerMinute"],
        "levelBonusRatio": f"{eco['levelBonusNum']}/{eco['levelBonusDen']}",
        "maxLevel": eco["maxLevel"],
        "outputAtLevel5": rows[4]["economyRate"] if len(rows) >= 5 else None,
        "rows": rows,
    }


def draw_svg(path, rows, cfg):
    """一张图两条曲线:蓝柱 = 升级成本,红线 = 每级耗时(各自归一化)。"""
    W, H, PAD = 760, 420, 60
    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 = (
        "《合成小岛》成长线:升级成本(蓝柱)与每级耗时(红线)"
    )
    x0, x1, y0, y1 = PAD, W - 24, H - PAD, 52
    n = len(rows)
    max_cost = max(r["costToNext"] for r in rows) or 1
    max_min = max(r["minutesToNext"] for r in rows) or 1
    slot = (x1 - x0) / n
    bw = slot * 0.5
    # 水平网格
    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, "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"})
    cx = lambda i: x0 + slot * i + slot / 2
    # 成本柱
    for i, r in enumerate(rows):
        h = (y0 - y1) * r["costToNext"] / max_cost
        ET.SubElement(svg, "rect", {"x": f"{cx(i) - bw / 2:.1f}", "y": f"{y0 - h:.1f}",
                                    "width": f"{bw:.1f}", "height": f"{h:.1f}", "fill": "#93c5fd"})
        ET.SubElement(svg, "text", {"x": f"{cx(i):.1f}", "y": str(y0 + 14), "font-size": "10",
                                    "text-anchor": "middle", "fill": "#6b7280"}).text = str(r["level"])
    # 耗时折线
    sy = lambda m: y0 - (y0 - y1) * m / max_min
    pts = " ".join(f"{cx(i):.2f},{sy(r['minutesToNext']):.2f}" for i, r in enumerate(rows))
    ET.SubElement(svg, "polyline", {"points": pts, "fill": "none", "stroke": "#dc2626", "stroke-width": "2"})
    for i, r in enumerate(rows):
        ET.SubElement(svg, "circle", {"cx": f"{cx(i):.1f}", "cy": f"{sy(r['minutesToNext']):.1f}", "r": "3",
                                      "fill": "#dc2626"})
    # 图例
    ET.SubElement(svg, "rect", {"x": str(x0), "y": "34", "width": "12", "height": "12", "fill": "#93c5fd"})
    ET.SubElement(svg, "text", {"x": str(x0 + 18), "y": "45", "font-size": "12", "fill": "#374151"}).text = (
        f"升级成本(峰值 {max_cost})"
    )
    ET.SubElement(svg, "line", {"x1": str(x0 + 210), "y1": "40", "x2": str(x0 + 234), "y2": "40",
                                "stroke": "#dc2626", "stroke-width": "2"})
    ET.SubElement(svg, "text", {"x": str(x0 + 240), "y": "45", "font-size": "12", "fill": "#374151"}).text = (
        f"每级耗时(峰值 {max_min} 分钟)"
    )
    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)
    rows = curve(cfg)

    os.makedirs(args.out, exist_ok=True)
    with open(os.path.join(args.out, "ch08_progression.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["level", "costToNext", "ratePerMinute", "minutesToNext", "cumCost", "unlock"])
        for r in rows:
            w.writerow([r["level"], r["costToNext"], r["ratePerMinute"], r["minutesToNext"], r["cumCost"], r["unlock"]])

    svg_bytes = draw_svg(os.path.join(args.out, "ch08_progression.svg"), rows, cfg)
    total_min = sum(r["minutesToNext"] for r in rows)
    report = {
        "version": cfg["version"],
        "config": os.path.join(args.config, "ch08_progression.json"),
        "maxLevel": cfg["maxLevel"],
        "costRatio": f"{cfg['costNum']}/{cfg['costDen']}",
        "rateRatio": f"{cfg['rateNum']}/{cfg['rateDen']}",
        "firstLevelMinutes": rows[0]["minutesToNext"],
        "lastLevelMinutes": rows[-1]["minutesToNext"],
        "totalMinutesToMax": total_min,
        "totalCumCost": rows[-1]["cumCost"],
        "curve": rows,
        "referenceEconomy": reference_check(cfg, load_reference(args.config)),
        "svg": os.path.join(args.out, "ch08_progression.svg"),
        "svgBytes": svg_bytes,
    }
    with open(os.path.join(args.out, "ch08_progression.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 (
        "maxLevel", "costRatio", "rateRatio", "firstLevelMinutes", "lastLevelMinutes",
        "totalMinutesToMax", "totalCumCost")}, ensure_ascii=False))
    return 0


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

在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):

{"maxLevel": 12, "costRatio": "140/100", "rateRatio": "115/100", "firstLevelMinutes": 5, "lastLevelMinutes": 45, "totalMinutesToMax": 231, "totalCumCost": 8348}

读法:满级 12 级,1 级升 2 级要攒 5 分钟,12 级升 13 级要 45 分钟;从 1 级练满 12 级一共 231 分钟(约 3.9 小时)的纯挂机时间,累计投入 8348 金币。同时落盘 out/ch08_progression.svg——蓝柱是升级成本、红线是每级耗时,两张曲线在同一张图上,一眼能看出「成本越涨越高、耗时也越拉越长」。

三条曲线怎么读

把 CSV 摊开看,成长线的形状就清楚了(out/ch08_progression.csv):

等级升级成本每分钟产出每级耗时累计投入解锁
160125 分钟60
284137 分钟144
3117158 分钟261是
41641810 分钟425
52302012 分钟655
63222414 分钟977是
74512717 分钟1428
86323121 分钟2060
98853625 分钟2945是
1012394230 分钟4184
1117354837 分钟5919
1224295545 分钟8348是

三条曲线各管一件事:

  1. 成本曲线(蓝柱)决定「门槛感」。它从 60 涨到 2429,跨度约 40 倍。这条曲线必须单调递增,否则会出现「升高级反而更便宜」的荒谬感。
  2. 产出曲线决定「变强感」。它从 12 涨到 55,跨度约 4.6 倍。它涨得比成本慢,正是玩家觉得「高级越来越难升」的来源。
  3. **耗时曲线(红线)**是前两者的商,决定「节奏」。它从 5 分钟涨到 45 分钟,跨度约 9 倍——这个跨度不是拍脑袋定的,而是 1.40 / 1.15 逐级复合出来的。

注意 unlock 列:每 3 级解锁一次新地块。它的作用是给这条连续的成长线插上离散的里程碑——玩家不一定记得自己几级,但一定记得「上次解锁新地块是多久前」。连续曲线负责日常推进,离散里程碑负责记忆点,两者缺一不可。

与 economy.json 的对照

sim/ch08_progression.py 会顺手读一眼站点既有的 config/economy.json,把两套配置的产出曲线逐级对齐。结果很有意思:前 12 级逐级 diff 全为 0。

原因不难找。economy.json 的 baseOutputPerMinute = 12、levelBonusNum/levelBonusDen = 115/100,而本章的 baseRatePerMinute = 12、rateNum/rateDen = 115/100——两条产出曲线用的是同一组参数,所以算出来必然逐位相同(economy.json 的 5 级产出是 20,本章脚本读到的也是 20)。

那本章到底比 economy.json 多给了什么?答案是成本侧。economy.json 只建模了产出随等级增长,它的 secondsToLevel 是拿「产出 × 60」当分母直接除出来的,隐含假设是「成本恒定」。而真实成长线的成本必然递增,本章补上的正是这条 costNum/costDen = 140/100 的成本曲线。有了成本增速,才谈得上「每级耗时」这个真正决定节奏的量。

一句话:economy.json 回答「产出怎么涨」,本章回答「成本怎么涨、两者赛跑的结果是什么」。这也是本节标题叫「成长线与数值曲线」而不是「产出曲线」的原因——成长是两条曲线的商,不是一条曲线。

落到设计上的三条规则

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

  1. 先定耗时增速,再反推两条增速。 不要分别去调成本和产出,那样会陷入「调了成本忘了产出」的循环。先决定「每级比上一级多花多少时间」(本例是约 22%),再让成本增速 = 产出增速 × 耗时增速。
  2. 成本必须单调递增,且要能算总账。 成本曲线要一路涨,同时用 cumCost 盯住总投入——玩家练满一档要花多少金币,这个总数决定了它和付费、广告的关系(第 10、13 章)。
  3. 连续曲线 + 离散里程碑。 等级是连续的,解锁是离散的。只有连续曲线,玩家记不住进度;只有离散里程碑,日常又没有推进感。两者叠加,成长线才立得住。

必须诚实标注:baseCost = 60、两条增速比都是设计假设,不是从任何真实产品后台量出来的。本节能确定的是曲线形状(单调、可复合)与当前落点(5 分钟 → 45 分钟、满级 231 分钟)这两个可复现的结论;真实产品该定多少,需要留存与付费数据反推,那属于「需真实业务环境,命令行不可复现」的范畴。

下一节把这套「成长」收进玩家的单次会话里:进度条涨到哪、什么时候该弹出「再来一局」、近失怎么驱动续玩——并用一个浏览器里点得动的原型把它验证出来。

阅读导航:上一节:关卡难度校准与自动生成 · 下一节:进度感与「再来一局」 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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