2.1 给的是值曲线:升一级变强多少。但真正决定玩家「多久能长大」的,是另一条曲线——成本曲线。值曲线决定「值不值」,成本曲线决定「够不够得着」。两者一起才是成长设计,缺一个都落不了地。本节只做成本侧,配置沿用 2.1 展示的 config/n02_curve.json,其中 cost 与 income 两段就是本节的输入。
成本曲线之所以是 SLG 的命门,是因为它直接决定了付费加速的价值。玩家愿意为「跳过等待」付钱,前提是等待足够长、且这条曲线足够陡。曲线太软,没人买加速;曲线太陡,玩家直接流失。数值策划真正在调的,就是这条曲线。
成本曲线的三件事
一条等级成本曲线由三个量描述,缺一不可:
- 单级花费:从 L 级升到 L+1 级要花多少资源。本方案用整数比复利
cost(L) = base · r^(L-1),其中r就是成本增长率。 - 累计花费:从 1 级一路升到 L 级,总共要花多少。它是单级花费的累加,是「养成总成本」的度量。
- 时间门槛:把累计花费除以玩家每小时的有效产出,得到「攒够这一级要几小时」。这一步把纯资源数字翻译成玩家真正感受到的东西——时间。
只有第三步落地,成本曲线才算完整。一个 15 万木材的升级,对每小时产 1.4 万木材的玩家是 11 小时,对每小时产 100 木材的新手是一辈子——同一个数字,两种体验。
复用 bastion_core 对账
本节不重造轮子。等级成本的计算逻辑与卷一 sim/bastion_core.py 的 upgrade_cost_at_level 完全同构(都是整数幂 + 地板除),本方案的成本参数又与 config/buildings.json 里伐木场的木材口径一致(base=120、增长率 128/100)。所以脚本会把前 10 级与 bastion_core 逐级对账,两边必须每一位都相等——这是「一份配置、两处消费」的又一次落地。
成本口径的两个常见坑
把成本曲线落进配置表前,有两个坑必须避开:
- 别用浮点写增长率。
1.28在二进制里是无限循环小数,Python 与 TypeScript 各自的舍入可能不同,跨语言对照必然漂移。本方案一律写整数比growthNum: 128 / growthDen: 100,两边用整数幂算,结果逐位一致。 - 别把「累计成本」和「单级成本」混用。累计成本是单级成本的积分,形状更平缓;拿累计成本去拟合增长率会得到一个偏小的值(2.3 会亲眼看到这个现象)。
这两条不是洁癖,而是让成本曲线可回归测试的前提:只要两边算得一样,改一个增长率就能立刻看到时间门槛的变化。
跑一遍
下面这段是 S 层仿真脚本,它逐级算花费、累计花费与时间门槛,与 bastion_core 对账,并做一组成本增长率敏感性扫描。全程整数运算,上取整用 (a+b-1)//b:
# file: sim/n02_cost_curve.py
# tier: S
# verify: python3 sim/n02_cost_curve.py --config config/ --out out/
"""等级与养成成本曲线(2.2):升级花费、累计花费与时间门槛。
读 config/n02_curve.json 的 cost/income 段,逐级算「升到下一级的花费」「升到该级的累计花费」
「每小时有效产出」与「攒够这一级要几小时」,并与 bastion_core.upgrade_cost_at_level(伐木场木材)
逐级对账。产出 out/n02_cost.json + out/n02_cost.csv + out/n02_cost.svg + out/n02_cost_hours.svg。
全程整数运算(指数用原生大整数,除法地板除,上取整用 (a+b-1)//b)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import sys
import xml.etree.ElementTree as ET
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import bastion_core as core # noqa: E402
SVG_NS = "http://www.w3.org/2000/svg"
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 cost_at(cost: dict, lv: int) -> int:
e = lv - 1
return cost["base"] * cost["growthNum"] ** e // cost["growthDen"] ** e
def income_at(inc: dict, lv: int) -> int:
e = lv - 1
return inc["base"] * inc["growthNum"] ** e // inc["growthDen"] ** e
def render_svg(path: str, vals: list[int], title: str, color: str) -> None:
width, height, pl, pr, pt, pb = 700, 400, 72, 24, 28, 44
iw, ih = width - pl - pr, height - pt - pb
n = len(vals)
hi = float(max(vals)) 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"})
ET.SubElement(svg, "polyline", {"points": " ".join(xy(i, vals[i]) for i in range(n)), "fill": "none", "stroke": color, "stroke-width": "2"})
ET.SubElement(svg, "text", {"x": str(pl), "y": "16", "font-size": "13", "fill": "#111"}).text = title
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")
cost, inc = cfg["cost"], cfg["income"]
n = cost["levels"]
eff = inc["efficiencyPermille"]
rows, cum_cost, cum_hours = [], 0, 0
for lv in range(1, n + 1):
c = cost_at(cost, lv)
rate = income_at(inc, lv) * eff // 1000
hours = (c + rate - 1) // rate # 上取整
cum_hours += hours
rows.append({"level": lv, "cost": c, "cumCost": cum_cost, "incomePerHour": income_at(inc, lv),
"ratePerHour": rate, "hoursToAfford": hours, "cumHours": cum_hours})
cum_cost += c
# 与 bastion_core 对账(伐木场木材,1..10 级)
lumberyard = next(b for b in core.load_config("buildings")["buildings"] if b["id"] == "lumberyard")
recon = [{"level": lv, "config": cost_at(cost, lv), "core": core.upgrade_cost_at_level(lumberyard, lv)["wood"]}
for lv in range(1, 11)]
recon_ok = all(r["config"] == r["core"] for r in recon)
# 敏感性:只改成本增长率,看时间门槛怎么收紧
sensitivity = []
for gnum in cost.get("sensitivityGrowthNum", []):
c2 = dict(cost, growthNum=gnum)
cum_h, h_last = 0, 0
for lv in range(1, n + 1):
c = cost_at(c2, lv)
rate = income_at(inc, lv) * eff // 1000
h = (c + rate - 1) // rate
cum_h += h
h_last = h
sensitivity.append({
"growthPermille": gnum * 10,
"costLast": cost_at(c2, n),
"hoursLast": h_last,
"cumHoursToMax": cum_h,
"daysToMax": (cum_h + inc["hoursPerDay"] - 1) // inc["hoursPerDay"],
})
first_over_day = next((r["level"] for r in rows if r["hoursToAfford"] >= inc["hoursPerDay"]), None)
report = {
"version": 1, "levels": n, "resource": cost["resource"],
"costFirst": rows[0]["cost"], "costLast": rows[-1]["cost"],
"costRatioPermille": rows[-1]["cost"] * 1000 // rows[0]["cost"],
"cumCostToMax": rows[-1]["cumCost"],
"hoursFirst": rows[0]["hoursToAfford"], "hoursLast": rows[-1]["hoursToAfford"],
"hoursRatioPermille": rows[-1]["hoursToAfford"] * 1000 // rows[0]["hoursToAfford"],
"cumHoursToMax": rows[-1]["cumHours"],
"daysToMax": (rows[-1]["cumHours"] + inc["hoursPerDay"] - 1) // inc["hoursPerDay"],
"firstLevelOverOneDay": first_over_day,
"reconcileCore": {"levelsChecked": 10, "allEqual": recon_ok},
"sensitivity": sensitivity,
}
with open(os.path.join(args.out, "n02_cost.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["level", "cost", "cumCost", "incomePerHour", "ratePerHour", "hoursToAfford", "cumHours"])
for r in rows:
w.writerow([r["level"], r["cost"], r["cumCost"], r["incomePerHour"], r["ratePerHour"], r["hoursToAfford"], r["cumHours"]])
with open(os.path.join(args.out, "n02_cost.json"), "w", encoding="utf-8") as f:
json.dump({"report": report, "reconcile": recon, "rows": rows}, f, ensure_ascii=False, indent=2)
render_svg(os.path.join(args.out, "n02_cost.svg"), [r["cost"] for r in rows], "upgrade cost per level (wood)", "#1f6feb")
render_svg(os.path.join(args.out, "n02_cost_hours.svg"), [r["hoursToAfford"] for r in rows], "hours to afford each level", "#c0392b")
print(f"cost L1={report['costFirst']} L{ n }={report['costLast']} ratio={report['costRatioPermille']}permille cumToMax={report['cumCostToMax']}")
print(f"gate hoursL1={report['hoursFirst']} hoursL{ n }={report['hoursLast']} ratio={report['hoursRatioPermille']}permille cumHours={report['cumHoursToMax']} daysToMax={report['daysToMax']}")
print(f"reconcile vs bastion_core (levels 1..10): allEqual={recon_ok}")
for s in sensitivity:
print(f" growth={s['growthPermille']}permille costLast={s['costLast']} hoursLast={s['hoursLast']} days={s['daysToMax']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑(Python 3.14.6)输出:
cost L1=120 L30=154266 ratio=1285550permille cumToMax=550507
gate hoursL1=3 hoursL30=11 ratio=3666permille cumHours=189 daysToMax=8
reconcile vs bastion_core (levels 1..10): allEqual=True
growth=1240permille costLast=61434 hoursLast=5 days=5
growth=1280permille costLast=154266 hoursLast=11 days=8
growth=1350permille costLast=722537 hoursLast=51 days=22
growth=1450permille costLast=5739225 hoursLast=400 days=105
读数
三行输出各说一件事:
- 成本:第 1 级 120 木材,第 30 级 154266 木材,末首比约 1285.6 倍(
1285550permille)。这就是 1.28 复利跑 29 级的威力——成本曲线是指数,涨幅远比直觉大。 - 时间门槛:第 1 级要 3 小时,第 30 级要 11 小时,只涨到 3.67 倍(
3666permille)。为什么成本涨了 1285 倍、时间只涨 3.67 倍?因为玩家的产出也在涨(收入增长率 1.22),两者赛跑,成本只略快一点。 - 对账:前 10 级与
bastion_core逐位相等(allEqual=True)。这证明本节的成本口径与卷一公共模块一致,不是另起炉灶。
注意这些是同机相对量级,绝对数值不可移植——换一份配置,绝对数会变,能比的是同一配置下「成本涨幅」与「时间涨幅」的差距。
敏感性:成本增长率是刀锋
成本增长率从 1.24 到 1.45,只动了 21 个千分点,满级时间却从 5 天翻到 105 天:
| 成本增长率 | 末级花费 | 末级时间门槛 | 满级天数 |
|---|---|---|---|
| 1.24 | 61434 | 5 小时 | 5 天 |
| 1.28(当前) | 154266 | 11 小时 | 8 天 |
| 1.35 | 722537 | 51 小时 | 22 天 |
| 1.45 | 5739225 | 400 小时 | 105 天 |
这张表是本节最有用的产出。它说明:成本增长率是一个极度敏感的参数,1.28 到 1.35 只差 7 个千分点,满级时间就从 8 天变成 22 天;再推到 1.45,直接变成 105 天——玩家要在游戏里泡三个多月才能满级。策划调这个数时,改的其实是玩家的整个游戏生涯长度,不能凭手感拍。
边界:纯资源门槛其实很软
还有一个反直觉的读数:在这个模型里,没有任何一级需要超过一天(firstLevelOverOneDay 为空),满级只要 8 天。原因就是上面说的——产出和成本同步指数增长,纯资源门槛会被自动「抬轿子」。
这给出一条重要结论:在 SLG 里,真正卡人的往往不是纯资源成本,而是非资源门槛——时间锁(建造队列、科技研究时长)、限次产出(每日任务、联盟捐献)、稀有道具(英雄碎片、图纸)。纯资源成本曲线决定的是「氪金加速能省多少」,而不是「多久能满级」。第 8 章谈赛季追赶、第 9 章谈付费点时,都会回到这条边界。
小结
成本曲线 = 单级花费 × 累计花费 × 时间门槛。单级花费是指数,累计花费是它的积分,时间门槛要拿产出曲线去折。本节跑出「成本涨 1285 倍、时间只涨 3.67 倍、满级 8 天」这组数字,并证明成本增长率是一个 7 个千分点就能把满级时间翻三倍的敏感参数。下一节换一个方向:不再从参数推到结果,而是从想要的结果反推参数。
阅读导航:上一节:2.1 成长曲线的四种家族:线性、指数、对数、分段 · 下一节:2.3 曲线拟合与反推目标体验 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。