2.1 成长曲线的四种家族:线性、指数、对数、分段

成长曲线只有四种基本家族:线性、指数、对数(饱和型)与分段。本节把四族放进同一份整数配置,在 1–30 级上逐级求值,用「末首倍率、加速比、中点占比」三个标量给曲线做体检,并跑出一张四族对比 SVG。指数与分段末值几乎相同,形状却完全相反——这正是选型的关键。

站内那篇独立游戏平衡与经济手册 讲的是单机/独立游戏口径:Hades 的夜之武器强化、Celeste 的章节难度、Slay the Spire 的卡牌升级,数值在通关那一刻就结束了,设计师要的是「一条好玩的曲线」。本节是SLG 持续运营口径:玩家不会通关,曲线要跨赛季一直活着,所以曲线不只是手感问题,还是付费节奏、追赶压力与膨胀控制的地基。两者都从「线性 / 指数 / 对数」这几个名字开始,但落点不同——那篇关心曲线好不好玩,本节关心曲线多久把人喂饱、又要多久不把人喂饱。

成长曲线是整个第 2 章的起点。它回答一个最朴素的问题:等级、建筑、科技往上爬时,数值该按什么规律长。第 1 章的战力公式负责「怎么把属性折成一个强度数」,本节负责「这个强度数随等级怎么长」,第 3 章才轮到一次交锋怎么结算。

四种曲线,其实是四种「体验承诺」

数值策划嘴里常说的四种曲线,本质是四种增长承诺:

  • 线性:每升一级,加的东西一样多。
  • 指数:每升一级,加的比上一级多固定比例——复利。
  • 对数(饱和型):先猛后缓,有上限,越往后越挤牙膏。
  • 分段:把上面几种按区间拼起来,前期一种规律、后期另一种。

名字是数学的,承诺是产品的。选哪种曲线,等于向玩家承诺「你越玩越爽」还是「你越玩越难」。

线性:每级等量,最容易被算穿

线性曲线的增量恒定。玩家的心算是:升一级要多久、升完强多少,这两个数一旦稳定,玩家就会自己排期。线性最容易理解,也最容易在几天内被玩家算穿——当玩家发现「下一级和上一级一样」,新鲜感就消失了。线性适合短周期、可预期的养成,比如前 10 级的引导段。

指数:复利,SLG 的默认选择

指数曲线的增量按固定比例放大:第 30 级的增量可能是第 1 级的几十倍。SLG 几乎默认用指数,因为指数天然匹配「资源产出也在指数增长」——玩家升级建筑,产出变多,就能负担更贵的下一级,形成一个自我强化的正反馈。指数的代价是:它是通胀的温床,也是「氪金加速」最有效的杠杆,所以指数曲线的参数(那个增长率)几乎就是 SLG 的付费命门。

对数:先陡后平,整数域里用一个有理替身

严格的对数函数在整数域不好表达,也不好在配置里写死(对数底不能是整数比)。本方案用一条饱和型有理曲线作为对数族的整数替身:

v(L) = base + scale · (L-1) / ((L-1) + h)

它和对数在形状上一致——先陡后平、有上界(上界是 base + scale),而且全程整数运算,Python 与 TypeScript 两侧逐位一致。对数族适合「越往后越难、但总有尽头」的场合,比如科技树里的边际收益递减。

分段:把三种拼起来

分段曲线把等级切成几段,每段用不同的规律。它是最贴近真实项目的形态,因为真实 SLG 的成长从来不是一条纯指数:前期要给新手快速的即时反馈(线性或缓指数),中期拉长养成周期(陡指数),后期用硬门槛卡住(更高斜率或分段墙)。分段的代价是接缝:段与段交界处若斜率跳变太狠,玩家会明显感到「卡墙」。

三个标量给曲线做体检

一条 30 级的曲线肉眼看不出性格,用三个整数标量就能把它压成可比较的指纹:

标量定义它在说什么
末首倍率v(末级) / v(首级),千分比整条曲线放大了多少倍
加速比末级增量 / 首级增量,千分比>1 越玩越猛,<1 越玩越钝
中点占比(v(中点) - v(首)) / (v(末) - v(首)),千分比一半的等级吃掉了多少成长

三个数一起看,曲线的「性格」就出来了:加速比和末首倍率一起涨的是指数,加速比低而中点占比高的是对数,两者都居中且中点占比低的往往是分段。

跑一遍:四族同台

下面这份是 D 层配置,它用整数参数定义了四族曲线、本章共用的等级成本曲线(2.2)、以及拟合锚点(2.3):

{
  "version": 1,
  "note": "第 2 章成长曲线:四族曲线的整数参数(2.1)、等级成本与产出曲线(2.2)、拟合锚点与目标体验(2.3)。增长率一律整数比,时间一律整数小时/天,禁止浮点。",
  "maxLevel": 30,
  "families": {
    "linear": { "note": "线性:每级等量加成,斜率恒定", "base": 100, "slope": 40 },
    "exponential": { "note": "指数:每级按固定整数比复利,越到后面越快", "base": 100, "growthNum": 112, "growthDen": 100 },
    "logarithmic": { "note": "对数族(饱和型有理替身):先陡后平、有上界,整数域用 base+scale*x/(x+h) 表达", "base": 100, "scale": 900, "halfNum": 8, "halfDen": 1 },
    "piecewise": { "note": "分段:前中后三段斜率递增,段界在 breaks", "base": 100, "breaks": [10, 20, 30], "slopes": [30, 70, 160] }
  },
  "cost": {
    "note": "等级成本曲线(2.2):升级花费 = base * growth^(lvl-1),与 buildings.json 伐木场木材口径一致",
    "levels": 30,
    "base": 120,
    "growthNum": 128,
    "growthDen": 100,
    "resource": "wood",
    "sensitivityGrowthNum": [124, 128, 135, 145]
  },
  "income": {
    "note": "每小时资源产出随等级提升(沿用伐木场口径),时间门槛 = 升级花费 / 每小时有效产出",
    "base": 60,
    "growthNum": 122,
    "growthDen": 100,
    "hoursPerDay": 24,
    "efficiencyPermille": 750
  },
  "fitting": {
    "note": "2.3 拟合:给一组目标累计成本锚点,用最小二乘反推指数增长率;目标体验为「第 D 天到 L 级」",
    "anchors": [
      { "level": 5, "cumCost": 580 },
      { "level": 10, "cumCost": 3100 },
      { "level": 15, "cumCost": 16800 },
      { "level": 20, "cumCost": 90000 },
      { "level": 25, "cumCost": 482000 },
      { "level": 30, "cumCost": 2593000 }
    ],
    "targetDays": [
      { "level": 10, "day": 7 },
      { "level": 20, "day": 21 },
      { "level": 30, "day": 45 }
    ]
  }
}

下面这段是 S 层仿真脚本,它在 1–30 级上给四族逐级求值,算三个标量,并画一张四族对比 SVG。全程整数运算:

# file: sim/n02_growth_family.py
# tier: S
# verify: python3 sim/n02_growth_family.py --config config/ --out out/
"""成长曲线四族(2.1):线性 / 指数 / 对数(饱和) / 分段。

读 config/n02_curve.json,在 1..maxLevel 上逐级求值,输出四族的关键标量
(末值、末首倍率、加速比、中点占比),并画一张四族对比 SVG。
产出:out/n02_family.json + out/n02_family.csv + out/n02_family.svg(纯 Python SVG)。
全程整数运算,禁止浮点(SVG 坐标除外)。
"""

from __future__ import annotations

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

SVG_NS = "http://www.w3.org/2000/svg"
COLORS = {"linear": "#1f6feb", "exponential": "#c0392b", "logarithmic": "#27ae60", "piecewise": "#e67e22"}


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


def linear(cfg: dict, lv: int) -> int:
    return cfg["base"] + cfg["slope"] * (lv - 1)


def exponential(cfg: dict, lv: int) -> int:
    e = lv - 1
    return cfg["base"] * cfg["growthNum"] ** e // cfg["growthDen"] ** e


def logarithmic(cfg: dict, lv: int) -> int:
    x = lv - 1
    h = cfg["halfNum"] // cfg["halfDen"]
    return cfg["base"] + cfg["scale"] * x // (x + h)


def piecewise(cfg: dict, lv: int) -> int:
    v = cfg["base"]
    breaks, slopes = cfg["breaks"], cfg["slopes"]
    for step in range(2, lv + 1):
        seg = 0
        while seg < len(breaks) and step > breaks[seg]:
            seg += 1
        v += slopes[min(seg, len(slopes) - 1)]
    return v


FAMILY = {"linear": linear, "exponential": exponential, "logarithmic": logarithmic, "piecewise": piecewise}


def series(fn, cfg: dict, n: int) -> list[int]:
    return [fn(cfg, lv) for lv in range(1, n + 1)]


def metrics(vals: list[int]) -> dict:
    n = len(vals)
    v1, vn = vals[0], vals[-1]
    mid = vals[n // 2 - 1]
    d_first = vals[1] - vals[0]
    d_last = vals[-1] - vals[-2]
    return {
        "first": v1,
        "last": vn,
        "ratioPermille": vn * 1000 // v1,
        "deltaFirst": d_first,
        "deltaLast": d_last,
        "accelPermille": d_last * 1000 // d_first if d_first else 0,
        "midLevel": n // 2,
        "midValue": mid,
        "midSharePermille": (mid - v1) * 1000 // (vn - v1) if vn != v1 else 0,
    }


def render_svg(path: str, curves: dict[str, list[int]], n: int) -> None:
    width, height, pl, pr, pt, pb = 700, 420, 64, 130, 28, 44
    iw, ih = width - pl - pr, height - pt - pb
    hi = float(max(max(v) for v in curves.values())) or 1.0

    def xy(i: int, v: int) -> str:
        return f"{pl + iw * (i / max(n - 1, 1)):.1f},{pt + ih * (1 - v / hi):.1f}"

    svg = ET.Element("svg", {"xmlns": SVG_NS, "viewBox": f"0 0 {width} {height}", "width": str(width), "height": str(height)})
    ET.SubElement(svg, "rect", {"width": str(width), "height": str(height), "fill": "#ffffff"})
    ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(pt), "x2": str(pl), "y2": str(height - pb), "stroke": "#333"})
    ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(height - pb), "x2": str(width - pr), "y2": str(height - pb), "stroke": "#333"})
    for i, (name, vals) in enumerate(curves.items()):
        ET.SubElement(svg, "polyline", {
            "points": " ".join(xy(j, vals[j]) for j in range(n)),
            "fill": "none", "stroke": COLORS[name], "stroke-width": "2",
        })
        ET.SubElement(svg, "text", {"x": str(width - pr + 10), "y": str(pt + 18 + i * 22), "font-size": "13", "fill": COLORS[name]}).text = name
    ET.SubElement(svg, "text", {"x": str(pl), "y": "16", "font-size": "13", "fill": "#111"}).text = "four growth-curve families (level 1..%d)" % n
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)


def main() -> int:
    here = os.path.dirname(os.path.abspath(__file__))
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default=os.path.join(here, "..", "config"))
    ap.add_argument("--out", default=os.path.join(here, "..", "out"))
    args = ap.parse_args()
    os.makedirs(args.out, exist_ok=True)

    cfg = load(args.config, "n02_curve")
    n = cfg["maxLevel"]
    curves = {name: series(fn, cfg["families"][name], n) for name, fn in FAMILY.items()}
    report = {name: metrics(vals) for name, vals in curves.items()}

    with open(os.path.join(args.out, "n02_family.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["level"] + list(curves.keys()))
        for i in range(n):
            w.writerow([i + 1] + [curves[k][i] for k in curves])

    payload = {"version": 1, "maxLevel": n, "families": report}
    with open(os.path.join(args.out, "n02_family.json"), "w", encoding="utf-8") as f:
        json.dump(payload, f, ensure_ascii=False, indent=2)

    render_svg(os.path.join(args.out, "n02_family.svg"), curves, n)
    for name, m in report.items():
        print(f"{name:12s} last={m['last']:<6d} ratio={m['ratioPermille']}permille accel={m['accelPermille']}permille midShare={m['midSharePermille']}permille")
    return 0


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

本机实跑(Python 3.14.6)输出:

linear       last=1260   ratio=12600permille accel=1000permille midShare=482permille
exponential  last=2674   ratio=26740permille accel=23833permille midShare=150permille
logarithmic  last=805    ratio=8050permille accel=50permille midShare=811permille
piecewise    last=2670   ratio=26700permille accel=5333permille midShare=241permille

读数:同终点,两条路

指数族与分段族的末值几乎一样——2674 与 2670,只差 4。但它们的三个标量完全不同:

  • 加速比:指数 23833‰,分段 5333‰。指数越到后面越猛,分段在段内是恒定的。
  • 中点占比:指数 150‰,分段 241‰。指数把绝大部分增长堆在后期(前半程只吃到 15%),分段则在前期就给得更多。

这组对照是本节最重要的一课:末值相同不代表体验相同。指数是「越玩越爽、但新手期很慢」,分段是「先给甜头、再慢慢上难度」。选曲线时,光看满级数值会被骗,必须看形状标量。

再看两个极端:线性族的加速比正好 1000‰(恒定增量),中点占比 482‰(几乎在正中间);对数族的加速比只有 50‰(末级增量是首级的二十分之一),中点占比高达 811‰——它把八成的成长压在前期,后期近乎封顶。注意这些是同机相对量级,绝对数值不可移植,能比的是同一配置下四族之间的相对大小。

原型层:同一份配置的第二处消费

同一份 config/n02_curve.json 在 TypeScript 原型层也能跑。关键函数是「指数族的整数幂」——它必须用 BigInt,因为 112^29 早已超过 2^53:

// snippet: 摘自 proto/src/n02_curve.ts —— 指数族用 BigInt 整数幂,避免 112^29 溢出
function ipow(base: number, exp: number): bigint {
  let acc = 1n;
  const b = BigInt(base);
  for (let i = 0; i < exp; i++) acc *= b;
  return acc;
}
function exponentialAt(cfg: ExpCfg, lv: number): number {
  const e = lv - 1;
  return Number((BigInt(cfg.base) * ipow(cfg.growthNum, e)) / ipow(cfg.growthDen, e));
}

工作区里的 proto/src/n02_curve.ts 与 proto/test/n02.test.ts 就是这条线的产物:node --test proto/test/n02.test.ts 的 5 项断言全部通过,四族末值、加速比、中点占比与 Python 侧逐位一致(线性 1260、指数 2674、对数 805、分段 2670)。一份配置、两处消费——改一个增长率,仿真与原型同时变,这就是把数值变成可回归测试对象的方式。

选型速查

场景推荐家族理由
新手引导前 10 级线性 / 缓分段反馈可预期,降低学习成本
主城建筑、主力养成指数匹配产出的指数增长,付费杠杆清晰
科技树、边际递减项对数(饱和)有上界,不会无限膨胀
长周期主线分段前期给甜头、中期拉长、后期卡墙

小结

成长曲线只有四族:线性、指数、对数(饱和型)、分段。它们不是数学练习,而是四种体验承诺。用「末首倍率 / 加速比 / 中点占比」三个标量可以给任意一条曲线做体检,指数与分段末值几乎相同、形状却相反,正是选型时要看清的东西。下一节把曲线落到成本上——等级越贵,玩家越慢,那条成本曲线才是真正卡住玩家的东西。

阅读导航:上一节:目录 · 下一节:2.2 等级与养成成本曲线 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引