11.1 消耗槽与养成消耗

资源到哪去:本节盘点 SLG 的六类消耗槽(建筑升级、科技研发、兵种训练、行军消耗、伤兵治疗、交易税),区分线性、指数、分段三种消耗曲线形态,并用 S 层脚本从 config/buildings.json 算出各等级升级成本表,折算成「回本时间」与产出速率对照。

11.1 消耗槽与养成消耗

第 10 章讲了资源从哪来,这一章反过来问:资源到哪去。站内已有一篇《游戏经济平衡设计》 ,那篇用放置类举例,给的是「产出与消耗要配平」的通用框架;本卷把同一件事落到 SLG 大地图地块口径上——产出来自地块与建筑,消耗被切成可枚举的槽,且每一步都能用本机的 bastion 脚本跑出数字。本节只做消耗侧,产出与消耗一起形成的闭环留给第 12 章。

一个常见误解是把「消耗」等同于「花钱」。SLG 的消耗是设计出来的槽:玩家把资源投进去,换回成长、战力或安全感。槽设计得好,资源被持续吸走,通胀压得住;槽设计得差,资源要么堆在仓库里发霉,要么被一个无底洞瞬间抽干、玩家失去节奏感。本节先把槽盘出来,再讲每类槽的曲线形态,最后用一个 S 层脚本把「升级成本」和「回本时间」算成表。

消耗槽盘点:资源到哪去

把《王朝纪元》的消耗出口盘一遍,得到六类槽。它们的驱动源、曲线形态和是否可回收(回收指资源回到玩家手里而非系统沉淀)各不相同:

消耗槽驱动曲线形态可回收说明
建筑升级进度指数否等级越高越贵,是长线资源的主要沉淀池
科技研发进度指数否与建筑升级竞争同一批资源,形成取舍
兵种训练军备线性否按兵种单价 × 数量,可批量重复
行军消耗活跃线性否按行军小时扣粮草,约束长距离投送
伤兵治疗战斗线性否按伤兵数扣资源,让战损有真实代价
交易税市场百分比是按成交额抽成,随税率变化(见 11.2)

前五类都是「系统沉淀」——资源投进去就消失,玩家换回的是数值成长。只有交易税是「摩擦成本」:它不创造成长,只是在玩家之间、玩家与市场之间抽一道水,作用是回收流通中的资源。第 9 章的资源与货币分层 已经说明,能沉淀的槽越多,货币体系越稳;而摩擦型槽的价值不在于抽多少,而在于把税率设在一个不至于把交易逼走的水平——这一点 11.2 会用曲线说清。

三种消耗曲线形态

槽的曲线形态决定了它消耗资源的「节奏感」:

  • 线性槽(训练 / 行军 / 治疗)。每单位消耗固定,总消耗 = 单价 × 数量。它随玩家活跃度等比增长,好处是可预期:多打一仗就多花一份粮草,玩家能算清账。坏处是它不构成长线目标——重复一万次还是同一个单价,撑不起「变强」的想象。
  • 指数槽(建筑 / 科技)。成本按固定比率逐级递增。它是长线资源的主要去处:低级升得快、感觉爽,高级升得慢、变成追求。指数槽的风险是末端失控——如果成本增长率远高于产出增长率,后期玩家会陷入「攒资源只为升一级」的枯燥。
  • 分段槽(部分科技树、赛季建筑)。在若干等级区间用不同的增长率,比如前 5 级平缓、后 5 级陡峭。它用来在特定节点「卡一下」进度,配合版本更新投放新内容。

形态选错的代价是真实的。下面先用 S 层脚本把建筑升级这条指数槽算清楚:从 config/buildings.json 读出成本增长率,生成各等级升级成本表。

从 buildings.json 算出升级成本表

下面这段是 S 层仿真脚本,只读主控拥有的 config/buildings.json,算出每个建筑每一级的升级成本、累计成本,并输出 CSV。buildings.json 里成本增长率用整数比表达(upgradeCostGrowthNum / upgradeCostGrowthDen),整数幂保证跨语言逐位一致:

# file: sim/ch11_sink_slots.py
# tier: S
# verify: python3 sim/ch11_sink_slots.py --config config/ --out out/
"""消耗槽盘点与养成回本时间:只读 buildings.json,算出各建筑各等级的升级成本表,
再用资源相对价值把「成本」与「产出」折算到同一单位,算边际/累计回本时间。只做消耗侧。
产出 out/ch11_sink_slots.json / ch11_upgrade_cost.csv / ch11_sink_inventory.csv / ch11_payback.svg"""
import argparse, csv, json, os, xml.etree.ElementTree as ET

C = ["#1f6feb", "#d97706", "#059669", "#9333ea"]

def load(d, n):
    with open(os.path.join(d, f"{n}.json"), encoding="utf-8") as f:
        return json.load(f)

def out_at(b, lv):
    return (b["outputBase"] * b["outputGrowthNum"] ** (lv - 1)) // b["outputGrowthDen"] ** (lv - 1)

def cost_at(b, lv):
    n, d = b["upgradeCostGrowthNum"] ** (lv - 1), b["upgradeCostGrowthDen"] ** (lv - 1)
    return {r: v * n // d for r, v in b["upgradeCostBase"].items()}

def tech_at(t, lv):
    n, d = t["growthNum"] ** (lv - 1), t["growthDen"] ** (lv - 1)
    return {r: v * n // d for r, v in t["costBase"].items()}

def cval(cost, val):
    return sum(v * val.get(r, 1) for r, v in cost.items())

def rows_of(buildings, val):
    rows = []
    for b in buildings["buildings"]:
        cum = 0
        for lv in range(1, b["maxLevel"]):
            cv = cval(cost_at(b, lv), val); cum += cv
            v = val[b["resource"]]; o1, o2 = out_at(b, lv) * v, out_at(b, lv + 1) * v
            rows.append({"building": b["id"], "resource": b["resource"], "level": lv, "toLevel": lv + 1,
                         "costWood": cost_at(b, lv).get("wood", 0), "costStone": cost_at(b, lv).get("stone", 0),
                         "costValue": cv, "outputPerHour": out_at(b, lv + 1), "outputValue": o2,
                         "marginalPaybackHours": cv // (o2 - o1) if o2 > o1 else -1,
                         "cumulativeCostValue": cum, "cumulativePaybackHours": cum // o2 if o2 else -1})
    return rows

def inventory(cfg, buildings):
    val, ref = cfg["resourceValue"], cfg["referenceDay"]
    daily = 0
    for b in buildings["buildings"]:
        daily += cval(cost_at(b, cfg["referenceLevels"][b["id"]]), val)
    tech = cval(tech_at(cfg["tech"], cfg["referenceTechLevel"]), val)
    def lin(base, count):
        return cval({r: v * count for r, v in base.items()}, val)
    return [("building-upgrade", daily), ("tech-research", tech),
            ("troop-training", lin(cfg["unitCost"], ref["trainUnits"])),
            ("march-supply", lin(cfg["marchPerHour"], ref["marchHours"])),
            ("healing", lin(cfg["healPerWounded"], ref["healWounded"])),
            ("trade-tax", ref["tradesPerDay"] * ref["tradeAvgValue"] * ref["taxPct"] // 100)]

def draw(path, rows):
    by = {}
    for r in rows:
        by.setdefault(r["building"], []).append(r["marginalPaybackHours"])
    hi = max(v for ys in by.values() for v in ys); W, H, pl, pt, pb, pr = 720, 320, 64, 28, 48, 24
    iw, ih = W - pl - pr, H - pt - pb
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#fff"})
    ET.SubElement(svg, "text", {"x": str(pl), "y": "18", "font-size": "14"}).text = "边际回本时间(小时)随等级变化"
    ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(pt), "x2": str(pl), "y2": str(H - pb), "stroke": "#cbd5e1"})
    ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(H - pb), "x2": str(W - pr), "y2": str(H - pb), "stroke": "#cbd5e1"})
    for i, (name, ys) in enumerate(by.items()):
        n = max(len(ys) - 1, 1)
        pts = " ".join(f"{pl + iw * j / n:.1f},{pt + ih * (1 - v / hi):.1f}" for j, v in enumerate(ys))
        ET.SubElement(svg, "polyline", {"points": pts, "fill": "none", "stroke": C[i % 4], "stroke-width": "2"})
        ET.SubElement(svg, "text", {"x": str(pl + 10 + i * 140), "y": str(H - pb + 22), "font-size": "12", "fill": C[i % 4]}).text = name
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)

def main():
    ap = argparse.ArgumentParser(); ap.add_argument("--config", default="config/"); ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    cfg, bl = load(a.config, "ch11_sinks"), load(a.config, "buildings")
    rows, inv = rows_of(bl, cfg["resourceValue"]), inventory(cfg, bl)
    os.makedirs(a.out, exist_ok=True)
    cols = list(rows[0])
    with open(os.path.join(a.out, "ch11_upgrade_cost.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f); w.writerow(cols); [w.writerow([r[c] for c in cols]) for r in rows]
    with open(os.path.join(a.out, "ch11_sink_inventory.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f); w.writerow(["sink", "dailyValue"]); [w.writerow([k, v]) for k, v in inv]
    ly = [r for r in rows if r["building"] == "lumberyard"]
    summary = {"version": 1, "buildings": len(bl["buildings"]), "levelsPerBuilding": bl["buildings"][0]["maxLevel"] - 1,
               "lumberyardPaybackFirst": ly[0]["marginalPaybackHours"], "lumberyardPaybackLast": ly[-1]["marginalPaybackHours"],
               "sinkInventory": dict(inv), "totalDailyValue": sum(v for _, v in inv)}
    with open(os.path.join(a.out, "ch11_sink_slots.json"), "w", encoding="utf-8") as f:
        json.dump(summary, f, ensure_ascii=False, indent=2)
    draw(os.path.join(a.out, "ch11_payback.svg"), rows)
    print(json.dumps(summary, ensure_ascii=False))

if __name__ == "__main__":
    main()

本机实跑(python3 sim/ch11_sink_slots.py --config config/ --out out/)输出的 CSV 里,伐木场的前三级是这样的(costValue 是把木材、石料按资源相对价值折算后的合计):

buildinglevelcostWoodcostStonecostValueoutputPerHourmarginalPaybackHours
lumberyard1120802007315
lumberyard21531022558915
lumberyard319613132710817

成本按 128% 逐级增长,产出按 122% 增长——成本涨得比产出快,这正是指数槽的固有张力。

回本时间:把成本和产出折算到同一单位

升级花的资源(木材 + 石料)和产出赚的资源(木材)不是同一种东西,直接比没有意义。脚本的做法是引入一组资源相对价值(config/ch11_sinks.json 里的 resourceValue:木材 1、石料 1、铁矿 3、粮草 1),把成本和产出都折算成同一个「价值」单位,于是:

  • 边际回本时间 = 升这一级的成本价值 ÷ 升完这一级后每小时多产出的价值。它回答「升这一级,靠它自己多产的部分,多久能赚回成本」。
  • 累计回本时间 = 升到该级为止的累计成本价值 ÷ 该级每小时的产出价值。它回答「把这座建筑养到这一级,总投入多久能被当前产出覆盖」。

实跑结果:伐木场的边际回本时间从 1 级的 15 小时升到 9 级的 22 小时;冶铁厂因为产出的是相对价值更高的铁矿,边际回本时间从 25 小时升到 38 小时。回本时间随等级单调上升(整数地板除会有 ±2 的局部抖动),这是指数槽的共同规律——越到后期,单靠产出越难回本,必须靠战力、解锁、排名这些非产出收益来补足动机。这也是数值策划调曲线时的第一根标尺:如果某座建筑的高级回本时间暴涨到玩家明显感知「不值」,要么压成本增长率,要么给它挂一个非产出收益。

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

上面是 Python 侧的算法。同一份 config/ 还能被 TypeScript 原型消费,得到逐位一致的结果——这是本套「一份配置两处消费」的门禁。下面这段是 P 层原型,用 BigInt 做整数幂(128^9 已超过 2^53,普通 number 会丢精度),算法与 Python 侧同构:

// file: proto/src/ch11_sinks.ts
// tier: P
// verify: node --test 'proto/**/*.test.ts'
// 与 sim/ch11_sink_slots.py 同构:读同一份 buildings.json + ch11_sinks.json,
// 用 BigInt 整数幂算出升级成本表与回本时间,结果与 Python 侧逐位一致。

import { loadConfig, outputAtLevel, upgradeCostAtLevel, type BuildingDef } from "./core.ts";

interface SinksConfig {
  resourceValue: Record<string, number>;
  referenceLevels: Record<string, number>;
  referenceTechLevel: number;
  referenceDay: Record<string, number>;
  unitCost: Record<string, number>;
  marchPerHour: Record<string, number>;
  healPerWounded: Record<string, number>;
  tech: { maxLevel: number; costBase: Record<string, number>; growthNum: number; growthDen: number };
}

const cfg = () => loadConfig<SinksConfig>("ch11_sinks");

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 cval(cost: Record<string, number>, value: Record<string, number>): number {
  let s = 0;
  for (const [r, v] of Object.entries(cost)) s += v * (value[r] ?? 1);
  return s;
}

function techAt(t: SinksConfig["tech"], level: number): Record<string, number> {
  const n = ipow(t.growthNum, level - 1), d = ipow(t.growthDen, level - 1);
  const out: Record<string, number> = {};
  for (const [r, v] of Object.entries(t.costBase)) out[r] = Number((BigInt(v) * n) / d);
  return out;
}

export interface PaybackRow {
  building: string; level: number; costValue: number; outputPerHour: number;
  marginalPaybackHours: number; cumulativeCostValue: number; cumulativePaybackHours: number;
}

export function paybackRows(): PaybackRow[] {
  const c = cfg(), value = c.resourceValue;
  const rows: PaybackRow[] = [];
  for (const b of loadConfig<{ buildings: BuildingDef[] }>("buildings").buildings) {
    let cum = 0;
    for (let level = 1; level < b.maxLevel; level++) {
      const cv = cval(upgradeCostAtLevel(b, level), value); cum += cv;
      const v = value[b.resource] ?? 1;
      const o1 = outputAtLevel(b, level) * v, o2 = outputAtLevel(b, level + 1) * v;
      rows.push({
        building: b.id, level, costValue: cv, outputPerHour: outputAtLevel(b, level + 1),
        marginalPaybackHours: o2 > o1 ? Math.floor(cv / (o2 - o1)) : -1,
        cumulativeCostValue: cum, cumulativePaybackHours: o2 ? Math.floor(cum / o2) : -1,
      });
    }
  }
  return rows;
}

export function sinkInventory(): Record<string, number> {
  const c = cfg(), value = c.resourceValue, ref = c.referenceDay;
  const lin = (base: Record<string, number>, count: number) =>
    cval(Object.fromEntries(Object.entries(base).map(([r, v]) => [r, v * count])), value);
  let building = 0;
  for (const b of loadConfig<{ buildings: BuildingDef[] }>("buildings").buildings) {
    building += cval(upgradeCostAtLevel(b, c.referenceLevels[b.id] ?? 1), value);
  }
  return {
    "building-upgrade": building,
    "tech-research": cval(techAt(c.tech, c.referenceTechLevel), value),
    "troop-training": lin(c.unitCost, ref.trainUnits),
    "march-supply": lin(c.marchPerHour, ref.marchHours),
    "healing": lin(c.healPerWounded, ref.healWounded),
    "trade-tax": Math.floor((ref.tradesPerDay * ref.tradeAvgValue * ref.taxPct) / 100),
  };
}

配套断言在 proto/test/ch11.test.ts(11.2 末尾展示)。P 侧与 S 侧对同一份配置算出的成本表、回本时间、消耗槽单日价值完全一致。把六类槽在参考活跃度下折算成单日价值,得到:兵种训练 69000、交易税 9600、伤兵治疗 6000、科技研发 4648、建筑升级 2100、行军消耗 480,合计 91828。注意训练一项就占了大头——这说明在《王朝纪元》的参考口径里,军备消耗是最大的资源出口,建筑升级反而不是。

本节小结

这一节把消耗侧拆开了:

  1. 六类消耗槽:建筑升级、科技研发、兵种训练、行军消耗、伤兵治疗、交易税;前五类沉淀资源,最后一类是摩擦成本。
  2. 三种曲线形态:线性(可预期但无长线感)、指数(长线主池但有末端失控风险)、分段(在节点卡进度)。
  3. 回本时间是可跑的标尺:把成本和产出按资源相对价值折算到同一单位,得到随等级单调上升的回本曲线,用它判断高级升级是否还「值」。

下一节把交易税这类摩擦成本单独拿出来,用曲线扫一遍税率从 0% 到 20% 时,回收量、交易量、玩家净收益怎么变,并找出拐点。

阅读导航:上一节:10.3 产出曲线仿真 · 下一节:11.2 税率、手续费与消耗弹性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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