上一节把产出端拆成了速率、可控性、方差三个维度,算出了每小时的产出表。但那张表默认了一件事:时间被均匀地消耗。站内那篇通用的经济平衡框架 用放置类举例,产出几乎是「一直在涨」的连续函数,不区分玩家在不在;而 SLG 的大地图口径里,玩家一天只在线几个小时,剩下十几个小时建筑还在不在产、产了算不算,是必须先定下来的规则。这一节就把「时间」这个变量引进来。
同一份速率表,两种时间口径
上一节算出的连续速率(伐木 5 级 = 132 木材/小时)是一个瞬时量:它描述「如果这一小时玩家在结算,产出多少」。而玩家的一天并不是 24 小时都在结算:
- 在线:玩家在游戏里,建筑产出实时入账,这部分没有争议。
- 离线:玩家退出游戏,建筑理论上还能继续产,但要不要让它产、产多少、上限多久,全是设计选择。
这两段时间的产出规则一旦不同,同一份速率表就会分裂出完全不同的资源曲线。所以设计产出端时,「离线产出」是一个必须先回答的产品问题,而不是一个可以后补的技术细节。
在线与离线:产出到底按什么算
《王朝纪元》的口径是:连续型产出(建筑、地块)离线也照常累积,但有两个约束——一是离线上限,二是结算粒度。这里的取舍直接决定了游戏的社交压力和留存曲线:
- 如果离线完全不产:玩家必须长时间在线才能跟上进度,SLG 会变成「点卡式」的挂机压力,对上班族不友好,也与「放置一段、回来收割」的 SLG 节奏相悖。
- 如果离线全额产:产出与在线时长解耦,玩家离线越久攒得越多,反而削弱了「在线才有互动」的动力,联盟战、抢夺的动机都会被稀释。
- 如果离线有上限地产:取一个中间值——离线能攒,但攒到一定小时数就封顶,逼玩家定期上线「清仓」。这是绝大多数成熟 SLG 的选择。
本节的仿真就把这三种方案放在同一张图上对比。
三种离线方案:无 / 有 / 有上限
下面这份是 D 层配置,定义了在线小时数、离线上限与回归加成参数:
{
"version": 1,
"note": "产出与时间的关系:在线小时、离线产出上限、回归加成曲线。全部整数口径。",
"days": 30,
"resource": "wood",
"levels": { "lumberyard": 5, "quarry": 5, "ironworks": 3, "farm": 6 },
"onlineHoursPerDay": 4,
"offlineCapHours": 8,
"returnBonus": {
"offlineDayPoints": [1, 3, 7, 14, 30],
"bonusPermillePerDay": 150,
"capPermille": 3000,
"activeDays": 7
}
}
下面这段是 S 层仿真脚本:固定伐木场 5 级(132 木材/小时)、玩家每天在线 4 小时,分别跑三种方案 30 天,输出 CSV 曲线与纯 Python SVG 图:
# file: sim/ch10_online_offline.py
# tier: S
# verify: python3 sim/ch10_online_offline.py --config config/ --out out/
"""产出与时间的关系:对比「无离线产出 / 有离线产出 / 有上限离线产出」三种方案的资源曲线,
并给出回归玩家加成曲线。图用纯 Python(xml.etree)画 SVG,零依赖。
产出:out/ch10_online_offline.json + out/ch10_online_offline.csv + out/ch10_online_offline.svg
"""
from __future__ import annotations
import argparse, csv, json, os
import xml.etree.ElementTree as ET
SVG_NS = "http://www.w3.org/2000/svg"
def load(cfg_dir, name):
with open(os.path.join(cfg_dir, f"{name}.json"), encoding="utf-8") as f:
return json.load(f)
def building_rate(cfg_dir, resource, levels):
rate = 0
for b in load(cfg_dir, "buildings")["buildings"]:
lv = levels.get(b["id"], 0)
if b["resource"] == resource and lv > 0:
rate += (b["outputBase"] * b["outputGrowthNum"] ** (lv - 1)) // b["outputGrowthDen"] ** (lv - 1)
return rate
def simulate(cfg, rate):
"""三种离线方案的每日产出与累计曲线。"""
days, online, cap = cfg["days"], cfg["onlineHoursPerDay"], cfg["offlineCapHours"]
offline = 24 - online
daily = {
"noOffline": rate * online,
"fullOffline": rate * (online + offline),
"cappedOffline": rate * (online + min(offline, cap)),
}
curves = {}
for k, d in daily.items():
acc, seq = 0, []
for _ in range(days):
acc += d; seq.append(acc)
curves[k] = seq
return daily, curves
def return_bonus(cfg, rate):
rb, online = cfg["returnBonus"], cfg["onlineHoursPerDay"]
base = rate * online
rows = []
for od in rb["offlineDayPoints"]:
bonus = min(rb["capPermille"], rb["bonusPermillePerDay"] * od)
boosted = base * (1000 + bonus) // 1000
rows.append({"offlineDays": od, "bonusPermille": bonus, "baseDaily": base,
"boostedDaily": boosted, "extraOverActiveDays": (boosted - base) * rb["activeDays"]})
return rows
def render_svg(path, curves):
width, height, pl, pr, pt, pb = 680, 400, 56, 24, 28, 44
iw, ih = width - pl - pr, height - pt - pb
days = len(next(iter(curves.values())))
hi = max(max(v) for v in curves.values()) or 1
colors = {"noOffline": "#c0392b", "fullOffline": "#1f6feb", "cappedOffline": "#27ae60"}
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"})
for k, series in curves.items():
pts = " ".join(f"{pl + iw * (i / max(days - 1, 1)):.1f},{pt + ih * (1 - v / hi):.1f}" for i, v in enumerate(series))
ET.SubElement(svg, "polyline", {"points": pts, "fill": "none", "stroke": colors[k], "stroke-width": "2"})
for i, k in enumerate(curves):
yy = pt + 4 + i * 18
ET.SubElement(svg, "line", {"x1": str(width - 190), "y1": str(yy), "x2": str(width - 160), "y2": str(yy), "stroke": colors[k], "stroke-width": "3"})
ET.SubElement(svg, "text", {"x": str(width - 154), "y": str(yy + 4), "font-size": "12", "fill": "#111"}).text = k
ET.SubElement(svg, "text", {"x": str(pl), "y": "16", "font-size": "13", "fill": "#111"}).text = "cumulative production: 3 offline schemes"
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
def main():
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, "ch10_timing")
rate = building_rate(args.config, cfg["resource"], cfg["levels"])
daily, curves = simulate(cfg, rate)
rb = return_bonus(cfg, rate)
with open(os.path.join(args.out, "ch10_online_offline.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f); w.writerow(["day", "noOffline", "fullOffline", "cappedOffline"])
for i in range(cfg["days"]):
w.writerow([i + 1, curves["noOffline"][i], curves["fullOffline"][i], curves["cappedOffline"][i]])
with open(os.path.join(args.out, "ch10_return_bonus.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f); w.writerow(["offlineDays", "bonusPermille", "baseDaily", "boostedDaily", "extraOverActiveDays"])
for r in rb:
w.writerow([r["offlineDays"], r["bonusPermille"], r["baseDaily"], r["boostedDaily"], r["extraOverActiveDays"]])
report = {
"version": 1, "resource": cfg["resource"], "ratePerHour": rate, "days": cfg["days"],
"onlineHoursPerDay": cfg["onlineHoursPerDay"], "offlineCapHours": cfg["offlineCapHours"],
"daily": daily,
"cumulativeFinal": {k: curves[k][-1] for k in curves},
"fullToNoRatioPct": daily["fullOffline"] * 100 // daily["noOffline"],
"cappedToFullPct": daily["cappedOffline"] * 100 // daily["fullOffline"],
"returnBonus": rb,
}
with open(os.path.join(args.out, "ch10_online_offline.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
render_svg(os.path.join(args.out, "ch10_online_offline.svg"), curves)
print(json.dumps({k: report[k] for k in ("ratePerHour", "daily", "cumulativeFinal", "fullToNoRatioPct", "cappedToFullPct")}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑(Python 3.14.6)得到:
ratePerHour: 132
daily: noOffline=528 fullOffline=3168 cappedOffline=1584
cumulativeFinal(30d): noOffline=15840 fullOffline=95040 cappedOffline=47520
fullToNoRatioPct: 600 cappedToFullPct: 50
这三行数字就是「离线规则」的全部威力。逐条读:
- 无离线:每天只产
132 × 4 = 528,30 天累计 15840。 - 全额离线:每天
132 × 24 = 3168,30 天累计 95040——是前者的 6 倍(fullToNoRatioPct = 600)。 - 上限离线:每天
132 × (4 + min(20, 8)) = 1584,30 天累计 47520——正好是全额的 50%(cappedToFullPct = 50)。
换句话说,「离线要不要产」这一个开关,能把玩家 30 天的资源总量在三倍区间内来回拉。它比调任何一条产出增长率都更省事、影响更大。SVG 图(out/ch10_online_offline.svg)把三条累计曲线画在一起:红线最平、蓝线最陡、绿线居中,视觉上一眼能看出方案之间的差距。这些是同机相对量级,绝对数值不可移植。
离线产出该不该有:三条判据
三种方案没有绝对的对错,但可以用三条判据来选:
- 进度感:无离线产出的方案,玩家一旦几天不上线就会「掉队感」强烈;有离线(哪怕是上限版)能兜住这一点。
- 互动动机:全额离线会让「在线互动」失去稀缺性——反正离线也照样产,联盟战、抢夺的动力被稀释。上限版把「清仓」变成一个需要上线的动作,保住了互动钩子。
- 经济水位:离线产出直接抬高全服资源总量,进而抬高消耗端压力(第 11 章)与通胀风险(第 13 章)。上限越高,闭环越难收敛。
《王朝纪元》选的是上限版,上限取 8 小时——大约是「一个正常睡眠周期 + 一次通勤」的尺度,逼玩家每天至少上线一次。
离线上限怎么设
上限不是一个拍脑袋的小时数,它对应一个明确的设计意图。可以这样反推:
- 若上限 < 在线时长,离线几乎不产,等于变相逼在线。
- 若上限 ≈ 在线时长,离线与在线权重相当,玩家「上线一次」就能拿到接近全天的产出。
- 若上限 > 在线时长,离线成为主要来源,玩家可以「离线挂机」,互动被削弱。
本方案在线 4 小时、离线上限 8 小时,上限是在线时长的 2 倍,让「每天上线清一次仓」能拿到 (4+8)/24 = 50% 的满产。这个比例(本方案取 50%)是运营侧最直观的调节旋钮:想更逼在线就往下调,想更宽容就往上调。
回归玩家:加成曲线
离线规则的极端情形是回归玩家——离线了几天甚至几周的人回来。如果只按离线上限补,一个离线 30 天的玩家回来只能拿到 8 小时产出,等于永久损失了几周的进度,回归意愿会很低。所以成熟 SLG 会给一条回归加成曲线:离线越久,回来后一段时间内的产出加成越高,但封顶。
本方案的规则是:每离线 1 天给 150‰ 的产出加成,封顶 3000‰(3 倍),加成持续 7 天。实跑结果:
| 离线天数 | 加成(‰) | 每日产出(在线 4h) | 加成后每日 | 7 天累计多拿 |
|---|---|---|---|---|
| 1 | 150 | 528 | 607 | 553 |
| 3 | 450 | 528 | 765 | 1659 |
| 7 | 1050 | 528 | 1082 | 3878 |
| 14 | 2100 | 528 | 1636 | 7756 |
| 30 | 3000 | 528 | 2112 | 11088 |
读法:加成按离线天数线性增长,到 20 天触顶(150 × 20 = 3000),之后不再增加。这个封顶很关键——没有封顶的话,一个离线半年的玩家回来就能碾压全场,会摧毁在线玩家的竞争公平。封顶把「补偿」和「破坏平衡」隔开了。
原型层:把离线结算搬进 TypeScript
同一份 config/,仿真层算曲线,原型层跑可玩逻辑。下面是本章的 P 层断言(完整文件 proto/test/ch10.test.ts),它把三种方案的每日产出、累计产出、回归加成逐条钉死,且数值与 Python 侧逐位一致:
// file: proto/test/ch10.test.ts
// tier: P
// verify: node --test proto/test/ch10.test.ts
// 第 10 章断言:产出源表、离线方案曲线、回归加成、N 天产出曲线,数值与 Python 侧逐位一致。
import { test } from "node:test";
import assert from "node:assert/strict";
import { productionTable, sourceInventory, offlineSchemes, returnBonus, curveReport } from "../src/ch10_production.ts";
test("10.1 建筑小时产出表:4 建筑 × 10 级 = 40 行,关键点对齐", () => {
const rows = productionTable();
assert.equal(rows.length, 40);
const at = (bid: string, lv: number) => rows.find((r) => r.building === bid && r.level === lv)!.outputPerHour;
assert.equal(at("lumberyard", 1), 60);
assert.equal(at("lumberyard", 5), 132);
assert.equal(at("lumberyard", 10), 359);
assert.equal(at("ironworks", 1), 18);
assert.equal(at("farm", 10), 412);
});
test("10.1 产出源汇总:7 源(2 连续 / 5 脉冲),基础等级连续速率", () => {
const r = sourceInventory();
assert.equal(r.sourceCount, 7);
assert.equal(r.continuousCount, 2);
assert.equal(r.burstCount, 5);
assert.equal(r.levelRows, 40);
assert.equal(r.maxOutputPerHour, 412);
assert.deepEqual(r.continuousRatePerHour, { wood: 132, stone: 110, iron: 27, grain: 199 });
});
test("10.2 三种离线方案:每日与 30 天累计产出", () => {
const r = offlineSchemes();
assert.equal(r.ratePerHour, 132);
assert.deepEqual(r.daily, { noOffline: 528, fullOffline: 3168, cappedOffline: 1584 });
assert.deepEqual(r.cumulativeFinal, { noOffline: 15840, fullOffline: 95040, cappedOffline: 47520 });
});
test("10.2 回归加成曲线:按离线天数线性增长并封顶", () => {
const rows = returnBonus();
assert.deepEqual(rows.map((x) => x.offlineDays), [1, 3, 7, 14, 30]);
assert.deepEqual(rows.map((x) => x.bonusPermille), [150, 450, 1050, 2100, 3000]);
assert.deepEqual(rows.map((x) => x.boostedDaily), [607, 765, 1082, 1636, 2112]);
assert.deepEqual(rows.map((x) => x.extraOverActiveDays), [553, 1659, 3878, 7756, 11088]);
});
test("10.3 N 天产出曲线:首末日与累计产出对齐 Python 侧", () => {
const r = curveReport();
assert.equal(r.days, 60);
assert.equal(r.dailyFirst, 8424);
assert.equal(r.dailyLast, 15066);
assert.equal(r.cumulativeFinal, 705272);
});
用 node --test proto/test/ch10.test.ts 跑,5 条全绿。同一份配置在 Python 与 TypeScript 两侧算出逐位一致的整数,这正是第 9 章账本模型要求的「产出可对账」在代码层面的落实。
小结
产出与时间的关系,本质上是一个产品选择:离线产不产、产多少、封顶多久、回归补多少。四个旋钮一起,把同一份速率表扭曲成了完全不同的资源曲线。本节把「在线 4h / 离线上限 8h / 回归加成封顶 3 倍」这组选择跑成了可复现的数字;至于这条曲线在整个经济闭环里合不合理,要等第 11 章的消耗端和第 12 章的闭环仿真来回答。
下一节把 10.1 的产出源和 10.2 的时间规则汇总成一条 N 天的产出曲线,并做拟合与残差检验。
阅读导航:上一节:产出源清单与产出速率 · 下一节:产出曲线仿真 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。