站内那篇独立游戏平衡与经济手册 讲的是单机/独立游戏口径: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 等级与养成成本曲线 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。