2.2 等级与养成成本曲线

2.1 讲的是「升级变强多少」,本节讲「升级要花多少」。升级成本按整数比复利增长,与每小时有效产出一比,就得到真正的门槛:攒够一级要几小时、满级要几天。我们复用 bastion_core 与伐木场口径逐级对账,并做一组成本增长率的敏感性扫描,看门槛怎么从 8 天翻到 105 天。

2.1 给的是值曲线:升一级变强多少。但真正决定玩家「多久能长大」的,是另一条曲线——成本曲线。值曲线决定「值不值」,成本曲线决定「够不够得着」。两者一起才是成长设计,缺一个都落不了地。本节只做成本侧,配置沿用 2.1 展示的 config/n02_curve.json,其中 cost 与 income 两段就是本节的输入。

成本曲线之所以是 SLG 的命门,是因为它直接决定了付费加速的价值。玩家愿意为「跳过等待」付钱,前提是等待足够长、且这条曲线足够陡。曲线太软,没人买加速;曲线太陡,玩家直接流失。数值策划真正在调的,就是这条曲线。

成本曲线的三件事

一条等级成本曲线由三个量描述,缺一不可:

  1. 单级花费:从 L 级升到 L+1 级要花多少资源。本方案用整数比复利 cost(L) = base · r^(L-1),其中 r 就是成本增长率。
  2. 累计花费:从 1 级一路升到 L 级,总共要花多少。它是单级花费的累加,是「养成总成本」的度量。
  3. 时间门槛:把累计花费除以玩家每小时的有效产出,得到「攒够这一级要几小时」。这一步把纯资源数字翻译成玩家真正感受到的东西——时间。

只有第三步落地,成本曲线才算完整。一个 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.24614345 小时5 天
1.28(当前)15426611 小时8 天
1.3572253751 小时22 天
1.455739225400 小时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 曲线拟合与反推目标体验 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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