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 是把木材、石料按资源相对价值折算后的合计):
| building | level | costWood | costStone | costValue | outputPerHour | marginalPaybackHours |
|---|---|---|---|---|---|---|
| lumberyard | 1 | 120 | 80 | 200 | 73 | 15 |
| lumberyard | 2 | 153 | 102 | 255 | 89 | 15 |
| lumberyard | 3 | 196 | 131 | 327 | 108 | 17 |
成本按 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。注意训练一项就占了大头——这说明在《王朝纪元》的参考口径里,军备消耗是最大的资源出口,建筑升级反而不是。
本节小结
这一节把消耗侧拆开了:
- 六类消耗槽:建筑升级、科技研发、兵种训练、行军消耗、伤兵治疗、交易税;前五类沉淀资源,最后一类是摩擦成本。
- 三种曲线形态:线性(可预期但无长线感)、指数(长线主池但有末端失控风险)、分段(在节点卡进度)。
- 回本时间是可跑的标尺:把成本和产出按资源相对价值折算到同一单位,得到随等级单调上升的回本曲线,用它判断高级升级是否还「值」。
下一节把交易税这类摩擦成本单独拿出来,用曲线扫一遍税率从 0% 到 20% 时,回收量、交易量、玩家净收益怎么变,并找出拐点。
阅读导航:上一节:10.3 产出曲线仿真 · 下一节:11.2 税率、手续费与消耗弹性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。