照例先划边界。站内那篇通用经济框架 讲的是放置类游戏的通用资源产出、消耗与通胀控制——它给的是一套可复用的经济骨架,回答「经济该怎么设计」;本章给的是小游戏成长曲线与离线收益的可跑模型,落到《合成小岛》的具体等级表、可复现的整数递推脚本,以及一个浏览器里点得动的原型上。那篇是通用框架,本章是「这条成长线具体该长什么样、怎么算出来、怎么验证」。
第 7 章把《合成小岛》的关卡难度铺成了一条心流曲线。但关卡只解决「这一关难不难」,不解决「为什么玩家明天还想来」。让玩家明天还想来的,是成长线:等级在涨、产出在涨、地图在扩,玩家能感觉到自己变强了。这一节就把成长线从一句感觉,变成一张能算、能改、能对照的表。
成长线到底在成长什么
「成长线」这个词很容易被含糊地使用。在《合成小岛》里,它其实是两条曲线的赛跑:
- 成本曲线:从第 L 级升到第 L+1 级,需要攒多少金币。它随等级递增。
- 产出曲线:玩家在第 L 级每分钟能产多少金币。它通常也随等级递增。
玩家感受到的「升级越来越慢」,本质是成本涨得比产出快。如果成本涨得和产出一样快,那每一级的耗时就是恒定的——玩家永远用同样的时间去换下一级,成长感被抹平。所以成长线的节奏,不取决于成本涨多少,也不取决于产出涨多少,而取决于两者的比值。
把这个比值写成公式,就是本章唯一需要记住的一条:
每级耗时增速 = 成本增速 ÷ 产出增速
成本增速 1.40、产出增速 1.15,耗时增速就是 1.40 / 1.15 ≈ 1.22——每一级比上一级多花约 22% 的时间。这个数字才是「成长线陡不陡」的真正旋钮。后面所有的设计讨论,都会回到它。
D 层:把成长线写成配置
下面这份是 D 层配置,落在 config/ch08_progression.json。它刻意用整数比表达两条曲线:成本按 costNum/costDen = 140/100 递增,产出按 rateNum/rateDen = 115/100 递增。配置里不出现任何浮点——这是全书的硬规矩,因为同一份数字要被 Python 仿真、TS 内核、浏览器原型三处消费,浮点会在跨语言时产生最后一位的偏差。
{
"version": 1,
"game": "merge-island",
"note": "成长线配置:等级 → 升级成本与产出速率。全部整数比递推,禁浮点。成本按 costNum/costDen 递增,产出按 rateNum/rateDen 递增;成本增速 ÷ 产出增速 = 每级耗时的增速,是成长线唯一的节奏旋钮。",
"maxLevel": 12,
"baseCost": 60,
"costNum": 140,
"costDen": 100,
"baseRatePerMinute": 12,
"rateNum": 115,
"rateDen": 100,
"unlockEveryLevels": 3
}
几个字段值得单独说:baseCost = 60 是 1 级升 2 级的金币门槛;baseRatePerMinute = 12 是 1 级的每分钟产出,它和站点既有的 config/economy.json 里的 baseOutputPerMinute = 12 完全一致,这不是巧合——两套配置建模的是同一款游戏的同一条产出线,只是本章多补了成本侧。unlockEveryLevels = 3 表示每 3 级解锁一次新地块,它是成长线上「看得见的里程碑」。
S 层:算出成本与每级耗时
有了配置,下面这段 S 层仿真脚本读 config/ 跑出整条成长线。它做三件事:用 BigInt 等价的整数幂算成本与产出、用整数上取整算每级耗时、顺手读一眼 config/economy.json 做逐级对照。
#!/usr/bin/env python3
# file: sim/ch08_progression.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch08_progression.py --config config/ --out out/
"""成长线模型:等级 → 升级成本、产出速率、每级耗时。全程整数运算。
cost(L) = baseCost * costNum^(L-1) // costDen^(L-1) 升到 L+1 需要的金币
rate(L) = baseRate * rateNum^(L-1) // rateDen^(L-1) 在 L 级的每分钟产出
minutes(L) = ceil(cost(L) / rate(L)) = (cost + rate - 1) // rate
cumCost(L) = cost(1) + ... + cost(L) 到 L 级前的累计投入
成本增速 ÷ 产出增速就是「每级耗时」的增速,这是成长线唯一的节奏旋钮:
costNum/rateNum = 140/115 ≈ 1.22 → 越往后每级越久。
另读 config/economy.json 做对照(那份只建模产出增长,没有独立的成本增速)。
产出:out/ch08_progression.json + out/ch08_progression.csv + out/ch08_progression.svg
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def load(cfg_dir):
with open(os.path.join(cfg_dir, "ch08_progression.json"), encoding="utf-8") as f:
return json.load(f)
def load_reference(cfg_dir):
"""economy.json 是主控拥有的通用放置经济配置,只读、只做对照。"""
path = os.path.join(cfg_dir, "economy.json")
if not os.path.exists(path):
return None
with open(path, encoding="utf-8") as f:
return json.load(f)
def cost_at(level, cfg):
"""升到 level+1 所需金币;level<1 返回 0。整数地板除。"""
if level < 1:
return 0
return (cfg["baseCost"] * cfg["costNum"] ** (level - 1)) // (cfg["costDen"] ** (level - 1))
def rate_at(level, cfg):
"""在 level 级的每分钟产出;level<1 返回 0。整数地板除。"""
if level < 1:
return 0
return (cfg["baseRatePerMinute"] * cfg["rateNum"] ** (level - 1)) // (
cfg["rateDen"] ** (level - 1)
)
def minutes_at(level, cfg):
"""在 level 级攒够升级金币所需分钟数,上取整(不用浮点 ceil)。"""
rate = rate_at(level, cfg)
if rate <= 0:
return 0
return (cost_at(level, cfg) + rate - 1) // rate
def curve(cfg):
rows, cum = [], 0
for lv in range(1, cfg["maxLevel"] + 1):
c = cost_at(lv, cfg)
r = rate_at(lv, cfg)
cum += c
rows.append(
{
"level": lv,
"costToNext": c,
"ratePerMinute": r,
"minutesToNext": minutes_at(lv, cfg),
"cumCost": cum,
"unlock": "是" if lv % cfg["unlockEveryLevels"] == 0 else "",
}
)
return rows
def reference_check(cfg, ref):
"""与 economy.json 的对照:同 baseRate 下,两条产出曲线的差在哪一级拉开。"""
if not ref:
return None
eco = ref["economy"]
max_lv = min(cfg["maxLevel"], eco["maxLevel"])
rows = []
for lv in range(1, max_lv + 1):
a = rate_at(lv, cfg)
b = (eco["baseOutputPerMinute"] * eco["levelBonusNum"] ** (lv - 1)) // (
eco["levelBonusDen"] ** (lv - 1)
)
rows.append({"level": lv, "ch08Rate": a, "economyRate": b, "diff": a - b})
return {
"source": "config/economy.json",
"baseOutputPerMinute": eco["baseOutputPerMinute"],
"levelBonusRatio": f"{eco['levelBonusNum']}/{eco['levelBonusDen']}",
"maxLevel": eco["maxLevel"],
"outputAtLevel5": rows[4]["economyRate"] if len(rows) >= 5 else None,
"rows": rows,
}
def draw_svg(path, rows, cfg):
"""一张图两条曲线:蓝柱 = 升级成本,红线 = 每级耗时(各自归一化)。"""
W, H, PAD = 760, 420, 60
svg = ET.Element(
"svg",
{"xmlns": "http://www.w3.org/2000/svg", "width": str(W), "height": str(H),
"viewBox": f"0 0 {W} {H}"},
)
ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(svg, "text", {"x": "16", "y": "26", "font-size": "15", "fill": "#111827"}).text = (
"《合成小岛》成长线:升级成本(蓝柱)与每级耗时(红线)"
)
x0, x1, y0, y1 = PAD, W - 24, H - PAD, 52
n = len(rows)
max_cost = max(r["costToNext"] for r in rows) or 1
max_min = max(r["minutesToNext"] for r in rows) or 1
slot = (x1 - x0) / n
bw = slot * 0.5
# 水平网格
for i in range(5):
y = y1 + (y0 - y1) * i / 4
ET.SubElement(svg, "line", {"x1": str(x0), "y1": f"{y:.1f}", "x2": str(x1), "y2": f"{y:.1f}",
"stroke": "#e5e7eb"})
ET.SubElement(svg, "line", {"x1": str(x0), "y1": str(y0), "x2": str(x1), "y2": str(y0), "stroke": "#9ca3af"})
ET.SubElement(svg, "line", {"x1": str(x0), "y1": str(y1), "x2": str(x0), "y2": str(y0), "stroke": "#9ca3af"})
cx = lambda i: x0 + slot * i + slot / 2
# 成本柱
for i, r in enumerate(rows):
h = (y0 - y1) * r["costToNext"] / max_cost
ET.SubElement(svg, "rect", {"x": f"{cx(i) - bw / 2:.1f}", "y": f"{y0 - h:.1f}",
"width": f"{bw:.1f}", "height": f"{h:.1f}", "fill": "#93c5fd"})
ET.SubElement(svg, "text", {"x": f"{cx(i):.1f}", "y": str(y0 + 14), "font-size": "10",
"text-anchor": "middle", "fill": "#6b7280"}).text = str(r["level"])
# 耗时折线
sy = lambda m: y0 - (y0 - y1) * m / max_min
pts = " ".join(f"{cx(i):.2f},{sy(r['minutesToNext']):.2f}" for i, r in enumerate(rows))
ET.SubElement(svg, "polyline", {"points": pts, "fill": "none", "stroke": "#dc2626", "stroke-width": "2"})
for i, r in enumerate(rows):
ET.SubElement(svg, "circle", {"cx": f"{cx(i):.1f}", "cy": f"{sy(r['minutesToNext']):.1f}", "r": "3",
"fill": "#dc2626"})
# 图例
ET.SubElement(svg, "rect", {"x": str(x0), "y": "34", "width": "12", "height": "12", "fill": "#93c5fd"})
ET.SubElement(svg, "text", {"x": str(x0 + 18), "y": "45", "font-size": "12", "fill": "#374151"}).text = (
f"升级成本(峰值 {max_cost})"
)
ET.SubElement(svg, "line", {"x1": str(x0 + 210), "y1": "40", "x2": str(x0 + 234), "y2": "40",
"stroke": "#dc2626", "stroke-width": "2"})
ET.SubElement(svg, "text", {"x": str(x0 + 240), "y": "45", "font-size": "12", "fill": "#374151"}).text = (
f"每级耗时(峰值 {max_min} 分钟)"
)
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
return len(ET.tostring(svg))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
cfg = load(args.config)
rows = curve(cfg)
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.out, "ch08_progression.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["level", "costToNext", "ratePerMinute", "minutesToNext", "cumCost", "unlock"])
for r in rows:
w.writerow([r["level"], r["costToNext"], r["ratePerMinute"], r["minutesToNext"], r["cumCost"], r["unlock"]])
svg_bytes = draw_svg(os.path.join(args.out, "ch08_progression.svg"), rows, cfg)
total_min = sum(r["minutesToNext"] for r in rows)
report = {
"version": cfg["version"],
"config": os.path.join(args.config, "ch08_progression.json"),
"maxLevel": cfg["maxLevel"],
"costRatio": f"{cfg['costNum']}/{cfg['costDen']}",
"rateRatio": f"{cfg['rateNum']}/{cfg['rateDen']}",
"firstLevelMinutes": rows[0]["minutesToNext"],
"lastLevelMinutes": rows[-1]["minutesToNext"],
"totalMinutesToMax": total_min,
"totalCumCost": rows[-1]["cumCost"],
"curve": rows,
"referenceEconomy": reference_check(cfg, load_reference(args.config)),
"svg": os.path.join(args.out, "ch08_progression.svg"),
"svgBytes": svg_bytes,
}
with open(os.path.join(args.out, "ch08_progression.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps({k: report[k] for k in (
"maxLevel", "costRatio", "rateRatio", "firstLevelMinutes", "lastLevelMinutes",
"totalMinutesToMax", "totalCumCost")}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):
{"maxLevel": 12, "costRatio": "140/100", "rateRatio": "115/100", "firstLevelMinutes": 5, "lastLevelMinutes": 45, "totalMinutesToMax": 231, "totalCumCost": 8348}
读法:满级 12 级,1 级升 2 级要攒 5 分钟,12 级升 13 级要 45 分钟;从 1 级练满 12 级一共 231 分钟(约 3.9 小时)的纯挂机时间,累计投入 8348 金币。同时落盘 out/ch08_progression.svg——蓝柱是升级成本、红线是每级耗时,两张曲线在同一张图上,一眼能看出「成本越涨越高、耗时也越拉越长」。
三条曲线怎么读
把 CSV 摊开看,成长线的形状就清楚了(out/ch08_progression.csv):
| 等级 | 升级成本 | 每分钟产出 | 每级耗时 | 累计投入 | 解锁 |
|---|---|---|---|---|---|
| 1 | 60 | 12 | 5 分钟 | 60 | |
| 2 | 84 | 13 | 7 分钟 | 144 | |
| 3 | 117 | 15 | 8 分钟 | 261 | 是 |
| 4 | 164 | 18 | 10 分钟 | 425 | |
| 5 | 230 | 20 | 12 分钟 | 655 | |
| 6 | 322 | 24 | 14 分钟 | 977 | 是 |
| 7 | 451 | 27 | 17 分钟 | 1428 | |
| 8 | 632 | 31 | 21 分钟 | 2060 | |
| 9 | 885 | 36 | 25 分钟 | 2945 | 是 |
| 10 | 1239 | 42 | 30 分钟 | 4184 | |
| 11 | 1735 | 48 | 37 分钟 | 5919 | |
| 12 | 2429 | 55 | 45 分钟 | 8348 | 是 |
三条曲线各管一件事:
- 成本曲线(蓝柱)决定「门槛感」。它从 60 涨到 2429,跨度约 40 倍。这条曲线必须单调递增,否则会出现「升高级反而更便宜」的荒谬感。
- 产出曲线决定「变强感」。它从 12 涨到 55,跨度约 4.6 倍。它涨得比成本慢,正是玩家觉得「高级越来越难升」的来源。
- **耗时曲线(红线)**是前两者的商,决定「节奏」。它从 5 分钟涨到 45 分钟,跨度约 9 倍——这个跨度不是拍脑袋定的,而是
1.40 / 1.15逐级复合出来的。
注意 unlock 列:每 3 级解锁一次新地块。它的作用是给这条连续的成长线插上离散的里程碑——玩家不一定记得自己几级,但一定记得「上次解锁新地块是多久前」。连续曲线负责日常推进,离散里程碑负责记忆点,两者缺一不可。
与 economy.json 的对照
sim/ch08_progression.py 会顺手读一眼站点既有的 config/economy.json,把两套配置的产出曲线逐级对齐。结果很有意思:前 12 级逐级 diff 全为 0。
原因不难找。economy.json 的 baseOutputPerMinute = 12、levelBonusNum/levelBonusDen = 115/100,而本章的 baseRatePerMinute = 12、rateNum/rateDen = 115/100——两条产出曲线用的是同一组参数,所以算出来必然逐位相同(economy.json 的 5 级产出是 20,本章脚本读到的也是 20)。
那本章到底比 economy.json 多给了什么?答案是成本侧。economy.json 只建模了产出随等级增长,它的 secondsToLevel 是拿「产出 × 60」当分母直接除出来的,隐含假设是「成本恒定」。而真实成长线的成本必然递增,本章补上的正是这条 costNum/costDen = 140/100 的成本曲线。有了成本增速,才谈得上「每级耗时」这个真正决定节奏的量。
一句话:economy.json 回答「产出怎么涨」,本章回答「成本怎么涨、两者赛跑的结果是什么」。这也是本节标题叫「成长线与数值曲线」而不是「产出曲线」的原因——成长是两条曲线的商,不是一条曲线。
落到设计上的三条规则
把这一节收成三条可以直接用的规则:
- 先定耗时增速,再反推两条增速。 不要分别去调成本和产出,那样会陷入「调了成本忘了产出」的循环。先决定「每级比上一级多花多少时间」(本例是约 22%),再让成本增速 = 产出增速 × 耗时增速。
- 成本必须单调递增,且要能算总账。 成本曲线要一路涨,同时用
cumCost盯住总投入——玩家练满一档要花多少金币,这个总数决定了它和付费、广告的关系(第 10、13 章)。 - 连续曲线 + 离散里程碑。 等级是连续的,解锁是离散的。只有连续曲线,玩家记不住进度;只有离散里程碑,日常又没有推进感。两者叠加,成长线才立得住。
必须诚实标注:baseCost = 60、两条增速比都是设计假设,不是从任何真实产品后台量出来的。本节能确定的是曲线形状(单调、可复合)与当前落点(5 分钟 → 45 分钟、满级 231 分钟)这两个可复现的结论;真实产品该定多少,需要留存与付费数据反推,那属于「需真实业务环境,命令行不可复现」的范畴。
下一节把这套「成长」收进玩家的单次会话里:进度条涨到哪、什么时候该弹出「再来一局」、近失怎么驱动续玩——并用一个浏览器里点得动的原型把它验证出来。
阅读导航:上一节:关卡难度校准与自动生成 · 下一节:进度感与「再来一局」 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。